Skip to content

Commit fbd57f6

Browse files
authored
feat: Adjust file changes toolbar + add diff counts (#209)
1 parent 9704ef0 commit fbd57f6

File tree

4 files changed

+137
-38
lines changed

4 files changed

+137
-38
lines changed

apps/array/src/main/services/git.ts

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ const execAsync = promisify(exec);
1313
const execFileAsync = promisify(execFile);
1414
const fsPromises = fs.promises;
1515

16+
const countFileLines = async (filePath: string): Promise<number> => {
17+
try {
18+
const content = await fsPromises.readFile(filePath, "utf-8");
19+
if (!content) return 0;
20+
21+
// Match git line counting: do not count trailing newline as extra line
22+
const lines = content.split("\n");
23+
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
24+
} catch {
25+
return 0;
26+
}
27+
};
28+
1629
const getAllFilesInDirectory = async (
1730
directoryPath: string,
1831
basePath: string,
@@ -194,26 +207,63 @@ const getChangedFilesAgainstHead = async (
194207
const files: ChangedFile[] = [];
195208
const seenPaths = new Set<string>();
196209

197-
// Use git diff with -M to detect renames in working tree
198-
const { stdout: diffOutput } = await execAsync(
199-
"git diff -M --name-status HEAD",
200-
{ cwd: directoryPath },
201-
);
210+
// Run git commands in parallel
211+
const [nameStatusResult, numstatResult, statusResult] = await Promise.all([
212+
execAsync("git diff -M --name-status HEAD", { cwd: directoryPath }),
213+
execAsync("git diff -M --numstat HEAD", { cwd: directoryPath }),
214+
execAsync("git status --porcelain", { cwd: directoryPath }),
215+
]);
216+
217+
// Build line stats map from numstat output
218+
// Format: ADDED\tREMOVED\tPATH or for renames: ADDED\tREMOVED\tOLD_PATH => NEW_PATH
219+
const lineStats = new Map<string, { added: number; removed: number }>();
220+
for (const line of numstatResult.stdout
221+
.trim()
222+
.split("\n")
223+
.filter(Boolean)) {
224+
const parts = line.split("\t");
225+
if (parts.length >= 3) {
226+
const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
227+
const removed = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
228+
const filePath = parts.slice(2).join("\t");
229+
// For renames, numstat shows "old => new" - extract both paths
230+
if (filePath.includes(" => ")) {
231+
const renameParts = filePath.split(" => ");
232+
// Store under both old and new path for lookup
233+
lineStats.set(renameParts[0], { added, removed });
234+
lineStats.set(renameParts[1], { added, removed });
235+
} else {
236+
lineStats.set(filePath, { added, removed });
237+
}
238+
}
239+
}
202240

203-
for (const line of diffOutput.trim().split("\n").filter(Boolean)) {
204-
// Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
241+
// Parse name-status output for file status
242+
// Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
243+
for (const line of nameStatusResult.stdout
244+
.trim()
245+
.split("\n")
246+
.filter(Boolean)) {
205247
const parts = line.split("\t");
206248
const statusChar = parts[0][0]; // First char (ignore rename percentage like R100)
207249

208250
if (statusChar === "R" && parts.length >= 3) {
209251
// Rename: R100\told-path\tnew-path
210252
const originalPath = parts[1];
211253
const newPath = parts[2];
212-
files.push({ path: newPath, status: "renamed", originalPath });
254+
const stats = lineStats.get(newPath) || lineStats.get(originalPath);
255+
files.push({
256+
path: newPath,
257+
status: "renamed",
258+
originalPath,
259+
linesAdded: stats?.added,
260+
linesRemoved: stats?.removed,
261+
});
213262
seenPaths.add(newPath);
214263
seenPaths.add(originalPath);
215264
} else if (parts.length >= 2) {
216265
const filePath = parts[1];
266+
const stats = lineStats.get(filePath);
217267
let status: GitFileStatus;
218268
switch (statusChar) {
219269
case "D":
@@ -225,23 +275,22 @@ const getChangedFilesAgainstHead = async (
225275
default:
226276
status = "modified";
227277
}
228-
files.push({ path: filePath, status });
278+
files.push({
279+
path: filePath,
280+
status,
281+
linesAdded: stats?.added,
282+
linesRemoved: stats?.removed,
283+
});
229284
seenPaths.add(filePath);
230285
}
231286
}
232287

233288
// Add untracked files from git status
234-
const { stdout: statusOutput } = await execAsync("git status --porcelain", {
235-
cwd: directoryPath,
236-
});
237-
238-
for (const line of statusOutput.trim().split("\n").filter(Boolean)) {
289+
for (const line of statusResult.stdout.trim().split("\n").filter(Boolean)) {
239290
const statusCode = line.substring(0, 2);
240291
const filePath = line.substring(3);
241292

242-
// Only add untracked files not already seen
243293
if (statusCode === "??" && !seenPaths.has(filePath)) {
244-
// Check if it's a directory (git shows directories with trailing /)
245294
if (filePath.endsWith("/")) {
246295
const dirPath = filePath.slice(0, -1);
247296
try {
@@ -251,14 +300,28 @@ const getChangedFilesAgainstHead = async (
251300
);
252301
for (const file of dirFiles) {
253302
if (!seenPaths.has(file)) {
254-
files.push({ path: file, status: "untracked" });
303+
const lineCount = await countFileLines(
304+
path.join(directoryPath, file),
305+
);
306+
files.push({
307+
path: file,
308+
status: "untracked",
309+
linesAdded: lineCount || undefined,
310+
});
255311
}
256312
}
257313
} catch {
258314
// Directory might not exist or be inaccessible
259315
}
260316
} else {
261-
files.push({ path: filePath, status: "untracked" });
317+
const lineCount = await countFileLines(
318+
path.join(directoryPath, filePath),
319+
);
320+
files.push({
321+
path: filePath,
322+
status: "untracked",
323+
linesAdded: lineCount || undefined,
324+
});
262325
}
263326
}
264327
}

apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function ChangedFileItem({
127127

128128
await window.electronAPI.discardFileChanges(
129129
repoPath,
130-
file.path,
130+
file.originalPath ?? file.path, // For renames, use the original path
131131
file.status,
132132
);
133133

@@ -138,6 +138,9 @@ function ChangedFileItem({
138138
});
139139
};
140140

141+
const hasLineStats =
142+
file.linesAdded !== undefined || file.linesRemoved !== undefined;
143+
141144
return (
142145
<Flex
143146
align="center"
@@ -154,13 +157,6 @@ function ChangedFileItem({
154157
paddingRight: "8px",
155158
}}
156159
>
157-
<Badge
158-
size="1"
159-
color={indicator.color}
160-
style={{ flexShrink: 0, fontSize: "10px", padding: "0 4px" }}
161-
>
162-
{indicator.label}
163-
</Badge>
164160
<FileIcon
165161
size={14}
166162
weight="regular"
@@ -190,18 +186,56 @@ function ChangedFileItem({
190186
>
191187
{file.originalPath ? `${file.originalPath}${file.path}` : file.path}
192188
</Text>
193-
<Tooltip content="Discard changes">
194-
<IconButton
195-
size="1"
196-
variant="ghost"
197-
color="gray"
198-
onClick={handleDiscard}
199-
className={isActive ? "" : "opacity-0 group-hover:opacity-100"}
200-
style={{ flexShrink: 0, width: "20px", height: "20px" }}
189+
190+
{hasLineStats && (
191+
<Flex
192+
align="center"
193+
gap="1"
194+
className="group-hover:hidden"
195+
style={{ flexShrink: 0, fontSize: "10px", fontFamily: "monospace" }}
201196
>
202-
<ArrowCounterClockwiseIcon size={12} />
203-
</IconButton>
204-
</Tooltip>
197+
{(file.linesAdded ?? 0) > 0 && (
198+
<Text style={{ color: "var(--green-9)" }}>+{file.linesAdded}</Text>
199+
)}
200+
{(file.linesRemoved ?? 0) > 0 && (
201+
<Text style={{ color: "var(--red-9)" }}>-{file.linesRemoved}</Text>
202+
)}
203+
</Flex>
204+
)}
205+
206+
<Flex
207+
align="center"
208+
gap="1"
209+
className="hidden group-hover:flex"
210+
style={{ flexShrink: 0 }}
211+
>
212+
<Tooltip content="Discard changes">
213+
<IconButton
214+
size="1"
215+
variant="ghost"
216+
color="gray"
217+
onClick={handleDiscard}
218+
style={{
219+
flexShrink: 0,
220+
width: "18px",
221+
height: "18px",
222+
padding: 0,
223+
marginLeft: "2px",
224+
marginRight: "2px",
225+
}}
226+
>
227+
<ArrowCounterClockwiseIcon size={12} />
228+
</IconButton>
229+
</Tooltip>
230+
</Flex>
231+
232+
<Badge
233+
size="1"
234+
color={indicator.color}
235+
style={{ flexShrink: 0, fontSize: "10px", padding: "0 4px" }}
236+
>
237+
{indicator.label}
238+
</Badge>
205239
</Flex>
206240
);
207241
}

apps/array/src/renderer/features/task-detail/components/ChangesTabBadge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function ChangesTabBadge({ taskId, task }: ChangesTabBadgeProps) {
3131
const filesLabel = diffStats.filesChanged === 1 ? "file" : "files";
3232

3333
return (
34-
<Flex gap="2">
34+
<Flex gap="2" mr="2">
3535
{diffStats.linesAdded > 0 && (
3636
<Text size="1">
3737
<Text size="1" color="green">

apps/array/src/shared/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ export interface ChangedFile {
184184
path: string;
185185
status: GitFileStatus;
186186
originalPath?: string; // For renames: the old path
187+
linesAdded?: number;
188+
linesRemoved?: number;
187189
}
188190

189191
// External apps detection types

0 commit comments

Comments
 (0)