claudeup 4.15.1 → 4.17.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.
- package/package.json +1 -1
- package/src/__tests__/gitignore-detector.test.ts +156 -0
- package/src/__tests__/gitignore-fixer.test.ts +197 -0
- package/src/__tests__/gitignore-prerun.test.ts +75 -0
- package/src/__tests__/gitignore-resolver.test.ts +166 -0
- package/src/__tests__/gitignore-service.test.ts +93 -0
- package/src/__tests__/useGitignoreModal.test.ts +71 -0
- package/src/data/gitignore-defaults.js +24 -0
- package/src/data/gitignore-defaults.ts +26 -0
- package/src/data/gitignore-templates.js +21 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.js +17 -1
- package/src/data/marketplaces.ts +16 -1
- package/src/prerunner/index.js +17 -0
- package/src/prerunner/index.ts +20 -0
- package/src/services/gitignore-detector.js +155 -0
- package/src/services/gitignore-detector.ts +157 -0
- package/src/services/gitignore-fixer.js +171 -0
- package/src/services/gitignore-fixer.ts +198 -0
- package/src/services/gitignore-prerun.js +46 -0
- package/src/services/gitignore-prerun.ts +59 -0
- package/src/services/gitignore-resolver.js +143 -0
- package/src/services/gitignore-resolver.ts +190 -0
- package/src/services/gitignore-service.js +99 -0
- package/src/services/gitignore-service.ts +142 -0
- package/src/types/gitignore.js +6 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +1 -0
- package/src/ui/App.js +47 -3
- package/src/ui/App.tsx +51 -2
- package/src/ui/components/TabBar.js +1 -0
- package/src/ui/components/TabBar.tsx +1 -0
- package/src/ui/hooks/useGitignoreModal.js +75 -0
- package/src/ui/hooks/useGitignoreModal.ts +97 -0
- package/src/ui/renderers/gitignoreRenderers.js +33 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
- package/src/ui/screens/GitignoreScreen.js +227 -0
- package/src/ui/screens/GitignoreScreen.tsx +364 -0
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/types.ts +4 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { useApp, useModal } from "../state/AppContext.js";
|
|
4
|
+
import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
|
|
5
|
+
import { ScreenLayout } from "../components/layout/index.js";
|
|
6
|
+
import {
|
|
7
|
+
loadGitignoreState,
|
|
8
|
+
applyAllSafeFixes,
|
|
9
|
+
applyDestructiveFix,
|
|
10
|
+
applyTemplate,
|
|
11
|
+
type GitignoreState,
|
|
12
|
+
} from "../../services/gitignore-service.js";
|
|
13
|
+
import { BUILTIN_TEMPLATES } from "../../data/gitignore-templates.js";
|
|
14
|
+
import { renderRuleRow, type RuleRow } from "../renderers/gitignoreRenderers.js";
|
|
15
|
+
import type { ResolvedRule, Violation } from "../../types/gitignore.js";
|
|
16
|
+
|
|
17
|
+
export function GitignoreScreen() {
|
|
18
|
+
const { state } = useApp();
|
|
19
|
+
const modal = useModal();
|
|
20
|
+
const cwd = state.projectPath;
|
|
21
|
+
|
|
22
|
+
const [data, setData] = useState<GitignoreState | null>(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
26
|
+
|
|
27
|
+
const reload = useCallback(async () => {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
const s = await loadGitignoreState(cwd);
|
|
32
|
+
setData(s);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}, [cwd]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void reload();
|
|
42
|
+
}, [reload]);
|
|
43
|
+
|
|
44
|
+
// Build the unified row list: every rule, sorted with violations first so
|
|
45
|
+
// problems are visible at the top of the list without scrolling.
|
|
46
|
+
const rows: RuleRow[] = useMemo(() => {
|
|
47
|
+
if (!data) return [];
|
|
48
|
+
const violationByPattern = new Map<string, Violation>();
|
|
49
|
+
for (const v of data.violations) {
|
|
50
|
+
// A violation's `path` is the rule's pattern for ignored-but-should-track
|
|
51
|
+
// and missing-from-gitignore; for tracked-but-should-ignore it's the
|
|
52
|
+
// actual tracked file under a directory rule. Index by rule pattern by
|
|
53
|
+
// matching either path or its prefix.
|
|
54
|
+
// Simplest: index by path; lookup will try exact then prefix.
|
|
55
|
+
violationByPattern.set(v.path, v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const annotated: RuleRow[] = data.resolved.rules.map((rule) => {
|
|
59
|
+
const v =
|
|
60
|
+
violationByPattern.get(rule.pattern) ??
|
|
61
|
+
findPrefixMatch(rule, data.violations);
|
|
62
|
+
return { rule, violation: v };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return annotated.sort((a, b) => {
|
|
66
|
+
const aBad = a.violation ? 0 : 1;
|
|
67
|
+
const bBad = b.violation ? 0 : 1;
|
|
68
|
+
if (aBad !== bBad) return aBad - bBad;
|
|
69
|
+
return a.rule.pattern.localeCompare(b.rule.pattern);
|
|
70
|
+
});
|
|
71
|
+
}, [data]);
|
|
72
|
+
|
|
73
|
+
// Clamp selection when rows shrink.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (selectedIdx >= rows.length) {
|
|
76
|
+
setSelectedIdx(Math.max(0, rows.length - 1));
|
|
77
|
+
}
|
|
78
|
+
}, [rows.length, selectedIdx]);
|
|
79
|
+
|
|
80
|
+
const selectedRow = rows[selectedIdx];
|
|
81
|
+
const violationCount = data?.violations.length ?? 0;
|
|
82
|
+
const safeCount =
|
|
83
|
+
data?.violations.filter((v) => v.severity === "safe").length ?? 0;
|
|
84
|
+
const destructiveCount = violationCount - safeCount;
|
|
85
|
+
|
|
86
|
+
// ── Action handlers ───────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const handleApplySafe = useCallback(async () => {
|
|
89
|
+
if (!data || safeCount === 0) return;
|
|
90
|
+
const r = await applyAllSafeFixes(cwd, data.violations);
|
|
91
|
+
await modal.message(
|
|
92
|
+
"Applied safe fixes",
|
|
93
|
+
`Appended ${r.appended.length} pattern(s) to .gitignore.${
|
|
94
|
+
r.alreadyPresent.length > 0
|
|
95
|
+
? `\n\n${r.alreadyPresent.length} pattern(s) were already present.`
|
|
96
|
+
: ""
|
|
97
|
+
}`,
|
|
98
|
+
"success",
|
|
99
|
+
);
|
|
100
|
+
await reload();
|
|
101
|
+
}, [cwd, data, safeCount, modal, reload]);
|
|
102
|
+
|
|
103
|
+
const handleApplySelected = useCallback(async () => {
|
|
104
|
+
if (!selectedRow?.violation) return;
|
|
105
|
+
const v = selectedRow.violation;
|
|
106
|
+
|
|
107
|
+
if (v.severity === "safe") {
|
|
108
|
+
await handleApplySafe();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const confirmed = await modal.confirm(
|
|
113
|
+
"Apply destructive fix?",
|
|
114
|
+
`${v.kind} on ${v.path}\n\n` +
|
|
115
|
+
`This mutates the git index (e.g. \`git rm --cached\` or \`git add\`).\n` +
|
|
116
|
+
`You can review the change with \`git status\` afterwards.`,
|
|
117
|
+
);
|
|
118
|
+
if (!confirmed) return;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = await applyDestructiveFix(cwd, v);
|
|
122
|
+
await modal.message(
|
|
123
|
+
result.applied ? "Fix applied" : "Fix failed",
|
|
124
|
+
result.message,
|
|
125
|
+
result.applied ? "success" : "error",
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
await modal.message(
|
|
129
|
+
"Fix failed",
|
|
130
|
+
err instanceof Error ? err.message : String(err),
|
|
131
|
+
"error",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
await reload();
|
|
135
|
+
}, [selectedRow, handleApplySafe, modal, cwd, reload]);
|
|
136
|
+
|
|
137
|
+
const handleApplyTemplate = useCallback(async () => {
|
|
138
|
+
const names = Object.keys(BUILTIN_TEMPLATES);
|
|
139
|
+
if (names.length === 0) {
|
|
140
|
+
await modal.message("No templates", "No built-in templates available.", "info");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const selected = await modal.select(
|
|
144
|
+
"Apply template",
|
|
145
|
+
"Merge the template into the project manifest at .claude/gitignore.json.",
|
|
146
|
+
names.map((n) => ({ label: n, value: n })),
|
|
147
|
+
);
|
|
148
|
+
if (!selected) return;
|
|
149
|
+
try {
|
|
150
|
+
const r = await applyTemplate(cwd, selected);
|
|
151
|
+
const conflictNote =
|
|
152
|
+
r.conflicts.length > 0
|
|
153
|
+
? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
|
|
154
|
+
: "";
|
|
155
|
+
await modal.message(
|
|
156
|
+
"Template applied",
|
|
157
|
+
`Wrote ${r.appliedTo}\n\n` +
|
|
158
|
+
`Added ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.` +
|
|
159
|
+
conflictNote,
|
|
160
|
+
"success",
|
|
161
|
+
);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
await modal.message(
|
|
164
|
+
"Template failed",
|
|
165
|
+
err instanceof Error ? err.message : String(err),
|
|
166
|
+
"error",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
await reload();
|
|
170
|
+
}, [cwd, modal, reload]);
|
|
171
|
+
|
|
172
|
+
const handleEditManifest = useCallback(async () => {
|
|
173
|
+
const projectManifest = `${cwd}/.claude/gitignore.json`;
|
|
174
|
+
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
|
|
175
|
+
await new Promise<void>((resolveEdit) => {
|
|
176
|
+
const child = spawn(editor, [projectManifest], { stdio: "inherit" });
|
|
177
|
+
child.on("exit", () => resolveEdit());
|
|
178
|
+
child.on("error", () => resolveEdit());
|
|
179
|
+
});
|
|
180
|
+
await reload();
|
|
181
|
+
}, [cwd, reload]);
|
|
182
|
+
|
|
183
|
+
useKeyboardHandler((input, key) => {
|
|
184
|
+
if (state.modal) return;
|
|
185
|
+
if (input === "j" || key.downArrow) {
|
|
186
|
+
setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (input === "k" || key.upArrow) {
|
|
190
|
+
setSelectedIdx((i) => Math.max(i - 1, 0));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (input === "f") {
|
|
194
|
+
void handleApplySafe();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (input === "F") {
|
|
198
|
+
void handleApplySelected();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (input === "t") {
|
|
202
|
+
void handleApplyTemplate();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (input === "e") {
|
|
206
|
+
void handleEditManifest();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (input === "r") {
|
|
210
|
+
void reload();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── Rendering ─────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
const summary = data ? renderSummary(violationCount, safeCount, destructiveCount) : null;
|
|
217
|
+
|
|
218
|
+
const listBody = loading ? (
|
|
219
|
+
<text fg="gray">Loading...</text>
|
|
220
|
+
) : error ? (
|
|
221
|
+
<text fg="red">Error: {error}</text>
|
|
222
|
+
) : rows.length === 0 ? (
|
|
223
|
+
<text fg="gray">No rules resolved.</text>
|
|
224
|
+
) : (
|
|
225
|
+
<box flexDirection="column">
|
|
226
|
+
{rows.slice(0, 200).map((row, i) => renderRuleRow(row, i === selectedIdx))}
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const listPanel = (
|
|
231
|
+
<box flexDirection="column">
|
|
232
|
+
{summary}
|
|
233
|
+
<text>{" "}</text>
|
|
234
|
+
{listBody}
|
|
235
|
+
</box>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const detailPanel = renderDetail(selectedRow, data?.warnings ?? []);
|
|
239
|
+
|
|
240
|
+
const subtitle = data
|
|
241
|
+
? violationCount === 0
|
|
242
|
+
? "all clean"
|
|
243
|
+
: `${violationCount} violation${violationCount === 1 ? "" : "s"}`
|
|
244
|
+
: "";
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<ScreenLayout
|
|
248
|
+
title="Gitignore Manager"
|
|
249
|
+
subtitle={subtitle}
|
|
250
|
+
currentScreen="gitignore"
|
|
251
|
+
footerHints="↑↓ move f fix-all-safe F fix-this t tpl e edit r reload"
|
|
252
|
+
listPanel={listPanel}
|
|
253
|
+
detailPanel={detailPanel}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function findPrefixMatch(
|
|
259
|
+
rule: ResolvedRule,
|
|
260
|
+
violations: Violation[],
|
|
261
|
+
): Violation | undefined {
|
|
262
|
+
// A directory rule like "ai-docs/sessions/" can have a violation on a child
|
|
263
|
+
// path like "ai-docs/sessions/leak.md". Match by path-startsWith-pattern.
|
|
264
|
+
if (!rule.pattern.endsWith("/") && !rule.pattern.includes("*")) return undefined;
|
|
265
|
+
const prefix = rule.pattern.replace(/\*+$/, "");
|
|
266
|
+
return violations.find((v) => v.path.startsWith(prefix));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderSummary(
|
|
270
|
+
violations: number,
|
|
271
|
+
safe: number,
|
|
272
|
+
destructive: number,
|
|
273
|
+
): React.ReactNode {
|
|
274
|
+
if (violations === 0) {
|
|
275
|
+
return <text fg="green">✓ All rules satisfied — nothing to fix.</text>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<text>
|
|
280
|
+
<span fg="white">{`${violations} violation${violations === 1 ? "" : "s"} `}</span>
|
|
281
|
+
<span fg="gray">{`(`}</span>
|
|
282
|
+
{safe > 0 && <span fg="yellow">{`${safe} safe`}</span>}
|
|
283
|
+
{safe > 0 && destructive > 0 && <span fg="gray">{`, `}</span>}
|
|
284
|
+
{destructive > 0 && <span fg="red">{`${destructive} destructive`}</span>}
|
|
285
|
+
<span fg="gray">{`)`}</span>
|
|
286
|
+
</text>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function renderDetail(
|
|
291
|
+
row: RuleRow | undefined,
|
|
292
|
+
warnings: string[],
|
|
293
|
+
): React.ReactNode {
|
|
294
|
+
if (!row) {
|
|
295
|
+
return (
|
|
296
|
+
<box flexDirection="column">
|
|
297
|
+
<text fg="gray">No rule selected.</text>
|
|
298
|
+
{warnings.length > 0 && (
|
|
299
|
+
<box flexDirection="column" marginTop={1}>
|
|
300
|
+
<text fg="yellow">Warnings:</text>
|
|
301
|
+
{warnings.map((w, i) => (
|
|
302
|
+
<text fg="gray" key={i}>{w}</text>
|
|
303
|
+
))}
|
|
304
|
+
</box>
|
|
305
|
+
)}
|
|
306
|
+
</box>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { rule, violation } = row;
|
|
311
|
+
|
|
312
|
+
if (!violation) {
|
|
313
|
+
// Rule is satisfied — show what it enforces and where it came from
|
|
314
|
+
return (
|
|
315
|
+
<box flexDirection="column">
|
|
316
|
+
<text fg="white"><strong>{rule.pattern}</strong></text>
|
|
317
|
+
<text>{" "}</text>
|
|
318
|
+
<text fg="green">{`✓ ${rule.action === "ignore" ? "Ignored" : "Tracked"} as required.`}</text>
|
|
319
|
+
<text>{" "}</text>
|
|
320
|
+
<text fg="gray">{`Rule from ${rule.source} tier.`}</text>
|
|
321
|
+
</box>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Rule is violated — explain and show the action
|
|
326
|
+
return (
|
|
327
|
+
<box flexDirection="column">
|
|
328
|
+
<text fg="white"><strong>{violation.path}</strong></text>
|
|
329
|
+
<text>{" "}</text>
|
|
330
|
+
<text fg="white">{describeKind(violation.kind)}</text>
|
|
331
|
+
<text>{" "}</text>
|
|
332
|
+
<text fg="gray">{`Rule from ${rule.source} tier.`}</text>
|
|
333
|
+
<text fg={violation.severity === "safe" ? "yellow" : "red"}>
|
|
334
|
+
{violation.severity === "safe"
|
|
335
|
+
? "Safe — only appends to .gitignore."
|
|
336
|
+
: "Destructive — mutates the git index."}
|
|
337
|
+
</text>
|
|
338
|
+
<text>{" "}</text>
|
|
339
|
+
<text fg="cyan">{actionHint(violation.kind)}</text>
|
|
340
|
+
</box>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function describeKind(kind: Violation["kind"]): string {
|
|
345
|
+
switch (kind) {
|
|
346
|
+
case "tracked-but-should-ignore": return "This file is in the git index but the manifest says it should be ignored.";
|
|
347
|
+
case "ignored-but-should-track": return "Your .gitignore excludes this path but the manifest says it should be tracked.";
|
|
348
|
+
case "untracked-and-should-track": return "This path exists on disk but git doesn't track it; the manifest says it should.";
|
|
349
|
+
case "missing-from-gitignore": return "The manifest says ignore this path, but it's not in any .gitignore line.";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function actionHint(kind: Violation["kind"]): string {
|
|
354
|
+
switch (kind) {
|
|
355
|
+
case "tracked-but-should-ignore":
|
|
356
|
+
return "[F] runs `git rm --cached` and appends a .gitignore entry.";
|
|
357
|
+
case "ignored-but-should-track":
|
|
358
|
+
return "[F] removes the matching .gitignore line and runs `git add`.";
|
|
359
|
+
case "untracked-and-should-track":
|
|
360
|
+
return "[F] runs `git add` to stage the path.";
|
|
361
|
+
case "missing-from-gitignore":
|
|
362
|
+
return "[f] appends this and every other safe entry to .gitignore.";
|
|
363
|
+
}
|
|
364
|
+
}
|
package/src/ui/screens/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { CliToolsScreen } from "./CliToolsScreen.js";
|
|
|
6
6
|
export { ModelSelectorScreen } from "./ModelSelectorScreen.js";
|
|
7
7
|
export { ProfilesScreen } from "./ProfilesScreen.js";
|
|
8
8
|
export { SkillsScreen } from "./SkillsScreen.js";
|
|
9
|
+
export { GitignoreScreen } from "./GitignoreScreen.js";
|
package/src/ui/screens/index.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { CliToolsScreen } from "./CliToolsScreen.js";
|
|
|
6
6
|
export { ModelSelectorScreen } from "./ModelSelectorScreen.js";
|
|
7
7
|
export { ProfilesScreen } from "./ProfilesScreen.js";
|
|
8
8
|
export { SkillsScreen } from "./SkillsScreen.js";
|
|
9
|
+
export { GitignoreScreen } from "./GitignoreScreen.js";
|
package/src/ui/state/types.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type Screen =
|
|
|
20
20
|
| "cli-tools"
|
|
21
21
|
| "model-selector"
|
|
22
22
|
| "profiles"
|
|
23
|
-
| "skills"
|
|
23
|
+
| "skills"
|
|
24
|
+
| "gitignore";
|
|
24
25
|
|
|
25
26
|
export type Route =
|
|
26
27
|
| { screen: "plugins" }
|
|
@@ -30,7 +31,8 @@ export type Route =
|
|
|
30
31
|
| { screen: "cli-tools" }
|
|
31
32
|
| { screen: "model-selector" }
|
|
32
33
|
| { screen: "profiles" }
|
|
33
|
-
| { screen: "skills" }
|
|
34
|
+
| { screen: "skills" }
|
|
35
|
+
| { screen: "gitignore" };
|
|
34
36
|
|
|
35
37
|
// ============================================================================
|
|
36
38
|
// Async Data Types
|