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.
- 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/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/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
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-
|
|
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
|
+
}
|