Skip to content

Commit 46878e8

Browse files
authored
🤖 Add untracked files status indicator to Code Review (#318)
## Summary Adds an UntrackedStatus component to the Code Review panel that displays the count of untracked files with an interactive tooltip for managing them. ## Changes ### New UntrackedStatus Component - Shows "N Untracked" badge next to the Dirty checkbox - Subtle styling when count is 0, highlighted (amber) when files exist - Interactive tooltip on click showing list of untracked files - "Track All" button to stage all untracked files at once - Integrates with refresh button to reload on Ctrl+R ### Bug Fixes - Fixed RefreshButton tooltip stuck on "Refreshing..." by splitting useEffect into separate concerns - Added performance pattern documentation to AGENTS.md about avoiding O(n) IPC calls ### UX Improvements - UntrackedStatus loads independently without blocking diff/tree loading - Uses single IPC call instead of O(n) calls per file - Consistent loading behavior with other components ## Testing - Verified untracked files display correctly - Tested "Track All" button stages files properly - Confirmed refresh button reloads all three data sources - Tooltip dismisses when clicking outside _Generated with `cmux`_
1 parent 3f77233 commit 46878e8

File tree

5 files changed

+276
-3
lines changed

5 files changed

+276
-3
lines changed

docs/AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ in `/tmp/ai-sdk-docs/**.mdx`.
139139
- Workspaces using git worktrees
140140
- Configuration persisted to `~/.cmux/config.json`
141141

142+
## Performance Patterns
143+
144+
**Avoid O(n) IPC calls from Frontend->Backend.** When displaying lists of items, fetch them in a single IPC call and process in the frontend. Never loop over items in the frontend making separate IPC calls for each.
145+
142146
## Package Manager
143147

144148
- **Using bun** - All dependencies are managed with bun (not npm)

src/components/RightSidebar/CodeReview/RefreshButton.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const RefreshButton: React.FC<RefreshButtonProps> = ({ onClick, isLoading
6464
const [animationState, setAnimationState] = useState<"idle" | "spinning" | "stopping">("idle");
6565
const spinOnceTimeoutRef = useRef<number | null>(null);
6666

67-
// Manage animation state based on loading prop
67+
// Watch isLoading changes and manage animation transitions
6868
useEffect(() => {
6969
if (isLoading) {
7070
// Start spinning
@@ -83,13 +83,16 @@ export const RefreshButton: React.FC<RefreshButtonProps> = ({ onClick, isLoading
8383
spinOnceTimeoutRef.current = null;
8484
}, 800); // Match animation duration
8585
}
86+
}, [isLoading, animationState]);
8687

88+
// Cleanup timeout on unmount only
89+
useEffect(() => {
8790
return () => {
8891
if (spinOnceTimeoutRef.current) {
8992
clearTimeout(spinOnceTimeoutRef.current);
9093
}
9194
};
92-
}, [isLoading, animationState]);
95+
}, []);
9396

9497
const className =
9598
animationState === "spinning" ? "spinning" : animationState === "stopping" ? "spin-once" : "";

src/components/RightSidebar/CodeReview/ReviewControls.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import styled from "@emotion/styled";
77
import { usePersistedState } from "@/hooks/usePersistedState";
88
import type { ReviewFilters, ReviewStats } from "@/types/review";
99
import { RefreshButton } from "./RefreshButton";
10+
import { UntrackedStatus } from "./UntrackedStatus";
1011

1112
interface ReviewControlsProps {
1213
filters: ReviewFilters;
1314
stats: ReviewStats;
1415
onFiltersChange: (filters: ReviewFilters) => void;
1516
onRefresh?: () => void;
1617
isLoading?: boolean;
18+
workspaceId: string;
19+
workspacePath: string;
20+
refreshTrigger?: number;
1721
}
1822

1923
const ControlsContainer = styled.div`
@@ -117,6 +121,9 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
117121
onFiltersChange,
118122
onRefresh,
119123
isLoading = false,
124+
workspaceId,
125+
workspacePath,
126+
refreshTrigger,
120127
}) => {
121128
// Local state for input value - only commit on blur/Enter
122129
const [inputValue, setInputValue] = useState(filters.diffBase);
@@ -196,9 +203,15 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
196203

197204
<CheckboxLabel>
198205
<input type="checkbox" checked={filters.includeDirty} onChange={handleDirtyToggle} />
199-
Include dirty
206+
Dirty
200207
</CheckboxLabel>
201208

209+
<UntrackedStatus
210+
workspaceId={workspaceId}
211+
workspacePath={workspacePath}
212+
refreshTrigger={refreshTrigger}
213+
/>
214+
202215
<Separator />
203216

204217
<StatBadge>

src/components/RightSidebar/CodeReview/ReviewPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,9 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
526526
onFiltersChange={setFilters}
527527
onRefresh={() => setRefreshTrigger((prev) => prev + 1)}
528528
isLoading={isLoadingHunks || isLoadingTree}
529+
workspaceId={workspaceId}
530+
workspacePath={workspacePath}
531+
refreshTrigger={refreshTrigger}
529532
/>
530533

531534
{error ? (
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* UntrackedStatus - Shows untracked files count with interactive tooltip
3+
*/
4+
5+
import React, { useState, useEffect, useRef } from "react";
6+
import styled from "@emotion/styled";
7+
import { keyframes } from "@emotion/react";
8+
9+
interface UntrackedStatusProps {
10+
workspaceId: string;
11+
workspacePath: string;
12+
refreshTrigger?: number;
13+
}
14+
15+
const Container = styled.div`
16+
position: relative;
17+
display: inline-block;
18+
`;
19+
20+
const Badge = styled.div<{ hasUntracked: boolean }>`
21+
padding: 4px 10px;
22+
border-radius: 3px;
23+
font-weight: 500;
24+
font-size: 11px;
25+
background: ${(props) => (props.hasUntracked ? "#3e2a00" : "transparent")};
26+
border: 1px solid ${(props) => (props.hasUntracked ? "#806000" : "transparent")};
27+
color: ${(props) => (props.hasUntracked ? "#ffb347" : "#888")};
28+
white-space: nowrap;
29+
cursor: ${(props) => (props.hasUntracked ? "pointer" : "default")};
30+
transition: all 0.2s ease;
31+
32+
&:hover {
33+
${(props) =>
34+
props.hasUntracked &&
35+
`
36+
background: #4a3200;
37+
border-color: #a07000;
38+
`}
39+
}
40+
`;
41+
42+
const fadeIn = keyframes`
43+
from {
44+
opacity: 0;
45+
transform: translateY(-4px);
46+
}
47+
to {
48+
opacity: 1;
49+
transform: translateY(0);
50+
}
51+
`;
52+
53+
const Tooltip = styled.div`
54+
position: absolute;
55+
top: calc(100% + 8px);
56+
right: 0;
57+
background: #2d2d30;
58+
border: 1px solid #454545;
59+
border-radius: 4px;
60+
padding: 8px;
61+
min-width: 200px;
62+
max-width: 400px;
63+
z-index: 1000;
64+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
65+
animation: ${fadeIn} 0.15s ease;
66+
`;
67+
68+
const TooltipHeader = styled.div`
69+
font-size: 11px;
70+
font-weight: 600;
71+
color: #ccc;
72+
margin-bottom: 8px;
73+
padding-bottom: 6px;
74+
border-bottom: 1px solid #3e3e42;
75+
`;
76+
77+
const FileList = styled.div`
78+
max-height: 200px;
79+
overflow-y: auto;
80+
margin-bottom: 8px;
81+
`;
82+
83+
const FileItem = styled.div`
84+
font-size: 11px;
85+
color: #aaa;
86+
padding: 3px 4px;
87+
font-family: var(--font-monospace);
88+
white-space: nowrap;
89+
overflow: hidden;
90+
text-overflow: ellipsis;
91+
92+
&:hover {
93+
background: #37373d;
94+
}
95+
`;
96+
97+
const TrackButton = styled.button`
98+
width: 100%;
99+
padding: 4px 8px;
100+
background: transparent;
101+
color: #888;
102+
border: 1px solid #444;
103+
border-radius: 3px;
104+
font-size: 11px;
105+
cursor: pointer;
106+
transition: all 0.2s ease;
107+
font-family: var(--font-primary);
108+
109+
&:hover {
110+
background: rgba(255, 255, 255, 0.05);
111+
color: #ccc;
112+
border-color: #666;
113+
}
114+
115+
&:active {
116+
background: rgba(255, 255, 255, 0.1);
117+
}
118+
119+
&:disabled {
120+
color: #555;
121+
border-color: #333;
122+
cursor: not-allowed;
123+
background: transparent;
124+
}
125+
`;
126+
127+
export const UntrackedStatus: React.FC<UntrackedStatusProps> = ({
128+
workspaceId,
129+
workspacePath,
130+
refreshTrigger,
131+
}) => {
132+
const [untrackedFiles, setUntrackedFiles] = useState<string[]>([]);
133+
const [isLoading, setIsLoading] = useState(false);
134+
const [showTooltip, setShowTooltip] = useState(false);
135+
const [isTracking, setIsTracking] = useState(false);
136+
const containerRef = useRef<HTMLDivElement>(null);
137+
const hasLoadedOnce = useRef(false);
138+
139+
// Load untracked files
140+
useEffect(() => {
141+
let cancelled = false;
142+
143+
const loadUntracked = async () => {
144+
// Only show loading on first load ever, not on subsequent refreshes
145+
if (!hasLoadedOnce.current) {
146+
setIsLoading(true);
147+
}
148+
149+
try {
150+
const result = await window.api.workspace.executeBash(
151+
workspaceId,
152+
"git ls-files --others --exclude-standard",
153+
{ timeout_secs: 5 }
154+
);
155+
156+
if (cancelled) return;
157+
158+
if (result.success) {
159+
const files = (result.data.output ?? "")
160+
.split("\n")
161+
.map((f) => f.trim())
162+
.filter(Boolean);
163+
setUntrackedFiles(files);
164+
}
165+
166+
hasLoadedOnce.current = true;
167+
} catch (err) {
168+
console.error("Failed to load untracked files:", err);
169+
} finally {
170+
setIsLoading(false);
171+
}
172+
};
173+
174+
void loadUntracked();
175+
176+
return () => {
177+
cancelled = true;
178+
};
179+
// eslint-disable-next-line react-hooks/exhaustive-deps
180+
}, [workspaceId, workspacePath, refreshTrigger]);
181+
182+
// Close tooltip when clicking outside
183+
useEffect(() => {
184+
if (!showTooltip) return;
185+
186+
const handleClickOutside = (e: MouseEvent) => {
187+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
188+
setShowTooltip(false);
189+
}
190+
};
191+
192+
document.addEventListener("mousedown", handleClickOutside);
193+
return () => document.removeEventListener("mousedown", handleClickOutside);
194+
}, [showTooltip]);
195+
196+
const handleTrackAll = async () => {
197+
if (untrackedFiles.length === 0 || isTracking) return;
198+
199+
setIsTracking(true);
200+
try {
201+
// Use git add with -- to treat all arguments as file paths
202+
// Escape single quotes by replacing ' with '\'' for safe shell quoting
203+
const escapedFiles = untrackedFiles.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(" ");
204+
const result = await window.api.workspace.executeBash(
205+
workspaceId,
206+
`git add -- ${escapedFiles}`,
207+
{ timeout_secs: 10 }
208+
);
209+
210+
if (result.success) {
211+
setUntrackedFiles([]);
212+
setShowTooltip(false);
213+
} else {
214+
console.error("Failed to track files:", result.error);
215+
}
216+
} catch (err) {
217+
console.error("Failed to track files:", err);
218+
} finally {
219+
setIsTracking(false);
220+
}
221+
};
222+
223+
const count = untrackedFiles.length;
224+
const hasUntracked = count > 0;
225+
226+
return (
227+
<Container ref={containerRef}>
228+
<Badge
229+
hasUntracked={hasUntracked}
230+
onClick={() => hasUntracked && setShowTooltip(!showTooltip)}
231+
>
232+
{isLoading ? "..." : `${count} Untracked`}
233+
</Badge>
234+
235+
{showTooltip && hasUntracked && (
236+
<Tooltip>
237+
<TooltipHeader>Untracked Files ({count})</TooltipHeader>
238+
<FileList>
239+
{untrackedFiles.map((file) => (
240+
<FileItem key={file}>{file}</FileItem>
241+
))}
242+
</FileList>
243+
<TrackButton onClick={() => void handleTrackAll()} disabled={isTracking}>
244+
{isTracking ? "Tracking..." : "Track All"}
245+
</TrackButton>
246+
</Tooltip>
247+
)}
248+
</Container>
249+
);
250+
};

0 commit comments

Comments
 (0)