Skip to content

Commit 4aa9edb

Browse files
committed
Fix changes panel updates
1 parent 00f1a77 commit 4aa9edb

7 files changed

Lines changed: 87 additions & 124 deletions

File tree

anycode-backend/src/git.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,17 +1254,19 @@ impl GitManager {
12541254

12551255
let x_char = stdout[0] as char;
12561256
let y_char = stdout[1] as char;
1257+
let untracked = x_char == '?' && y_char == '?';
12571258

12581259
let conflicted = x_char == 'U'
12591260
|| y_char == 'U'
12601261
|| (x_char == 'D' && y_char == 'D')
12611262
|| (x_char == 'A' && y_char == 'A');
12621263
let staged = x_char != ' ' && x_char != '?' && x_char != '!' && !conflicted;
1263-
let unstaged = y_char != ' ' && y_char != '?' && y_char != '!' && !conflicted;
1264+
let unstaged =
1265+
untracked || (y_char != ' ' && y_char != '?' && y_char != '!' && !conflicted);
12641266

12651267
let file_status = if conflicted {
12661268
FileStatus::Conflict
1267-
} else if x_char == '?' || x_char == 'A' || y_char == 'A' {
1269+
} else if untracked || x_char == 'A' || y_char == 'A' {
12681270
FileStatus::Added
12691271
} else if x_char == 'D' || y_char == 'D' {
12701272
FileStatus::Deleted

anycode-backend/src/tests/git.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ fn path_status_uses_repo_root_when_workdir_is_subdirectory() {
9595
assert!(status.unstaged);
9696
}
9797

98+
#[test]
99+
fn untracked_file_is_reported_as_unstaged_by_path_status() {
100+
let temp_dir = tempfile::TempDir::new().unwrap();
101+
Repository::init(temp_dir.path()).unwrap();
102+
103+
let file_path = temp_dir.path().join("new.txt");
104+
std::fs::write(&file_path, "new line\n").unwrap();
105+
106+
let mut manager = GitManager::new(temp_dir.path().to_path_buf());
107+
let status = manager
108+
.status_file_custom("new.txt")
109+
.unwrap()
110+
.expect("untracked file should be reported");
111+
112+
assert_eq!(status.status, FileStatus::Added);
113+
assert!(!status.staged);
114+
assert!(status.unstaged);
115+
}
116+
98117
#[test]
99118
fn deleted_file_numstat_counts_lines_not_bytes() {
100119
let temp_dir = tempfile::TempDir::new().unwrap();

anycode/components/ChangesPanel.tsx

Lines changed: 13 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
22
import { Icons } from './Icons';
33
import { FileIcon } from './FileIcon';
44
import './ChangesPanel.css';
@@ -57,51 +57,6 @@ const areChangedFileEqual = (prev: ChangedFile, next: ChangedFile): boolean => (
5757
&& (prev.removed ?? 0) === (next.removed ?? 0)
5858
);
5959

60-
const areChangedFileStructureEqual = (prev: ChangedFile, next: ChangedFile): boolean => (
61-
prev.path === next.path
62-
&& prev.status === next.status
63-
&& !!prev.staged === !!next.staged
64-
&& !!prev.unstaged === !!next.unstaged
65-
&& !!prev.conflicted === !!next.conflicted
66-
);
67-
68-
const updateStatsElement = (
69-
element: HTMLSpanElement | undefined,
70-
added: number,
71-
removed: number,
72-
) => {
73-
if (!element) {
74-
return;
75-
}
76-
77-
element.style.display = added > 0 || removed > 0 ? '' : 'none';
78-
79-
const addedElement = element.querySelector<HTMLElement>('[data-stat="added"]');
80-
if (addedElement) {
81-
addedElement.textContent = added > 0 ? `+${added}` : '';
82-
addedElement.style.display = added > 0 ? '' : 'none';
83-
}
84-
85-
const removedElement = element.querySelector<HTMLElement>('[data-stat="removed"]');
86-
if (removedElement) {
87-
removedElement.textContent = removed > 0 ? `-${removed}` : '';
88-
removedElement.style.display = removed > 0 ? '' : 'none';
89-
}
90-
};
91-
92-
const statsElementsByPath = new Map<string, Set<HTMLSpanElement>>();
93-
94-
export const updateChangesFileStats = (path: string, added: number, removed: number) => {
95-
const elements = statsElementsByPath.get(path);
96-
if (!elements) {
97-
return;
98-
}
99-
100-
for (const element of elements) {
101-
updateStatsElement(element, added, removed);
102-
}
103-
};
104-
10560
interface ChangesPanelItemProps {
10661
rowId: string;
10762
file: ChangedFile;
@@ -114,23 +69,21 @@ interface ChangesPanelItemProps {
11469
onStage: (path: string) => void;
11570
onUnstage: (path: string) => void;
11671
setItemRef: (rowId: string, element: HTMLDivElement | null) => void;
117-
setStatsRef: (path: string, element: HTMLSpanElement | null) => void;
11872
}
11973

12074
interface ChangesFileStatsProps {
121-
path: string;
122-
setStatsRef: (path: string, element: HTMLSpanElement | null) => void;
75+
added: number;
76+
removed: number;
12377
}
12478

125-
const ChangesFileStats = React.memo(({ path, setStatsRef }: ChangesFileStatsProps) => {
126-
const refCallback = useCallback((element: HTMLSpanElement | null) => {
127-
setStatsRef(path, element);
128-
}, [path, setStatsRef]);
129-
79+
const ChangesFileStats = React.memo(({ added, removed }: ChangesFileStatsProps) => {
80+
if (added === 0 && removed === 0) {
81+
return null;
82+
}
13083
return (
131-
<span className="changes-file-stats" ref={refCallback} style={{ display: 'none' }}>
132-
<span className="changes-stat-added" data-stat="added" />
133-
<span className="changes-stat-removed" data-stat="removed" />
84+
<span className="changes-file-stats">
85+
{added > 0 && <span className="changes-stat-added">+{added}</span>}
86+
{removed > 0 && <span className="changes-stat-removed">-{removed}</span>}
13487
</span>
13588
);
13689
});
@@ -148,7 +101,6 @@ const ChangesPanelItemImpl: React.FC<ChangesPanelItemProps> = ({
148101
onStage,
149102
onUnstage,
150103
setItemRef,
151-
setStatsRef,
152104
}) => {
153105
const handleRevert = useCallback((e: React.MouseEvent) => {
154106
e.stopPropagation();
@@ -213,7 +165,7 @@ const ChangesPanelItemImpl: React.FC<ChangesPanelItemProps> = ({
213165
{stageButtonLabel}
214166
</button>
215167
</div>
216-
<ChangesFileStats path={file.path} setStatsRef={setStatsRef} />
168+
<ChangesFileStats added={file.added ?? 0} removed={file.removed ?? 0} />
217169
</div>
218170
</div>
219171
);
@@ -233,8 +185,7 @@ const areChangesPanelItemsEqual = (
233185
&& prev.onStage === next.onStage
234186
&& prev.onUnstage === next.onUnstage
235187
&& prev.setItemRef === next.setItemRef
236-
&& prev.setStatsRef === next.setStatsRef
237-
&& areChangedFileStructureEqual(prev.file, next.file)
188+
&& areChangedFileEqual(prev.file, next.file)
238189
);
239190

240191
const ChangesPanelItem = React.memo(ChangesPanelItemImpl, areChangesPanelItemsEqual);
@@ -274,30 +225,6 @@ const ChangesPanelImpl: React.FC<ChangesPanelProps> = ({
274225
itemRefs.current.delete(rowId);
275226
}, []);
276227

277-
const setStatsRef = useCallback((path: string, element: HTMLSpanElement | null) => {
278-
const elements = statsElementsByPath.get(path);
279-
if (!element) {
280-
elements?.forEach((existingElement) => {
281-
if (!existingElement.isConnected) {
282-
elements.delete(existingElement);
283-
}
284-
});
285-
if (elements?.size === 0) {
286-
statsElementsByPath.delete(path);
287-
}
288-
return;
289-
}
290-
291-
if (!elements) {
292-
statsElementsByPath.set(path, new Set([element]));
293-
return;
294-
}
295-
296-
if (element) {
297-
elements.add(element);
298-
}
299-
}, []);
300-
301228
const conflictingFiles = useMemo(
302229
() => files.filter((file) => file.conflicted || file.status === 'conflict'),
303230
[files],
@@ -330,16 +257,6 @@ const ChangesPanelImpl: React.FC<ChangesPanelProps> = ({
330257
const stagedStats = useMemo(() => countGroupStats(stagedFiles), [countGroupStats, stagedFiles]);
331258
const changedStats = useMemo(() => countGroupStats(changedFiles), [changedFiles, countGroupStats]);
332259

333-
useLayoutEffect(() => {
334-
for (const row of displayedRows) {
335-
updateChangesFileStats(
336-
row.file.path,
337-
row.file.added ?? 0,
338-
row.file.removed ?? 0,
339-
);
340-
}
341-
}, [displayedRows]);
342-
343260
useEffect(() => {
344261
if (typeof window === 'undefined') return;
345262
if (message) {
@@ -532,10 +449,9 @@ const ChangesPanelImpl: React.FC<ChangesPanelProps> = ({
532449
onStage={onStage}
533450
onUnstage={onUnstage}
534451
setItemRef={setItemRef}
535-
setStatsRef={setStatsRef}
536452
/>
537453
))
538-
), [activeFilePath, handleItemClick, onRevert, onStage, onUnstage, selectedRowId, setItemRef, setStatsRef, fileIconsStyle]);
454+
), [activeFilePath, handleItemClick, onRevert, onStage, onUnstage, selectedRowId, setItemRef, fileIconsStyle]);
539455

540456
return (
541457
<div className="changes-panel">

anycode/hooks/useGit.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useCallback, useState } from 'react';
22
import type { Socket } from 'socket.io-client';
33
import type { ChangedFile } from '../components';
4-
import { updateChangesFileStats } from '../components/ChangesPanel';
54

65
type UseGitParams = {
76
wsRef: React.RefObject<Socket | null>;
@@ -49,20 +48,6 @@ const areChangedFilesEqual = (prev: ChangedFile, next: ChangedFile): boolean =>
4948
&& prev.removed === next.removed
5049
);
5150

52-
const areChangedFilesStructureEqual = (prev: ChangedFile, next: Omit<GitPatchItem, 'status'> & { status: ChangedFile['status'] }): boolean => (
53-
prev.path === next.path
54-
&& prev.status === next.status
55-
&& prev.staged === next.staged
56-
&& prev.unstaged === next.unstaged
57-
&& prev.conflicted === next.conflicted
58-
);
59-
60-
const updateFileStatsInPlace = (file: ChangedFile, added?: number, removed?: number) => {
61-
file.added = added;
62-
file.removed = removed;
63-
updateChangesFileStats(file.path, added ?? 0, removed ?? 0);
64-
};
65-
6651
export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
6752
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
6853
const [gitBranch, setGitBranch] = useState<string>('');
@@ -120,21 +105,20 @@ export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
120105
removed: item.removed,
121106
});
122107
structurallyChanged = true;
123-
} else if (areChangedFilesStructureEqual(existing, item)) {
124-
if (existing.added !== item.added || existing.removed !== item.removed) {
125-
updateFileStatsInPlace(existing, item.added, item.removed);
126-
}
127108
} else {
128-
next.set(item.path, {
109+
const updated: ChangedFile = {
129110
path: item.path,
130111
status: item.status,
131112
staged: item.staged,
132113
unstaged: item.unstaged,
133114
conflicted: item.conflicted,
134115
added: item.added,
135116
removed: item.removed,
136-
});
137-
structurallyChanged = true;
117+
};
118+
if (!areChangedFilesEqual(existing, updated)) {
119+
next.set(item.path, updated);
120+
structurallyChanged = true;
121+
}
138122
}
139123
}
140124
}
@@ -165,9 +149,6 @@ export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
165149
const b = nextFiles[i];
166150
if (areChangedFilesEqual(a, b)) {
167151
reusedFiles[i] = a;
168-
} else if (areChangedFilesStructureEqual(a, b)) {
169-
updateFileStatsInPlace(a, b.added, b.removed);
170-
reusedFiles[i] = a;
171152
} else {
172153
reusedFiles[i] = b;
173154
isDifferent = true;

anycode/shims/node-fs-promises.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const readFile = async () => {
2+
throw new Error("Node fs/promises is unavailable in the browser");
3+
};

anycode/shims/node-module.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const createRequire = () => {
2+
throw new Error("Node module.createRequire is unavailable in the browser");
3+
};

anycode/vite.config.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import { defineConfig } from "vite";
22
import react from "@vitejs/plugin-react";
3+
import { fileURLToPath, URL } from "node:url";
4+
5+
const vendorChunk = (id) => {
6+
if (!id.includes("node_modules") && !id.includes("/anycode-base/")) {
7+
return undefined;
8+
}
9+
if (id.includes("web-tree-sitter") || id.includes("/anycode-base/")) {
10+
return "editor";
11+
}
12+
if (id.includes("@xterm/")) {
13+
return "terminal";
14+
}
15+
if (id.includes("react-markdown") || id.includes("remark-") || id.includes("micromark")) {
16+
return "markdown";
17+
}
18+
if (id.includes("dockview")) {
19+
return "dockview";
20+
}
21+
if (id.includes("/react/") || id.includes("/react-dom/") || id.includes("scheduler")) {
22+
return "react";
23+
}
24+
return undefined;
25+
};
326

427
export default defineConfig(({ mode }) => {
528
const backendPort = process.env.ANYCODE_PORT || "3000";
@@ -9,6 +32,10 @@ export default defineConfig(({ mode }) => {
932
plugins: [react()],
1033
resolve: {
1134
extensions: [".ts", ".tsx", ".js", ".jsx"],
35+
alias: {
36+
"fs/promises": fileURLToPath(new URL("./shims/node-fs-promises.js", import.meta.url)),
37+
"module": fileURLToPath(new URL("./shims/node-module.js", import.meta.url)),
38+
},
1239
},
1340
assetsInclude: ["**/*.wasm"],
1441
hot: true,
@@ -23,6 +50,18 @@ export default defineConfig(({ mode }) => {
2350
build: {
2451
// Keep maps only for development packaging, not for production release embedding.
2552
sourcemap: mode === "development",
53+
rolldownOptions: {
54+
onLog(level, log, handler) {
55+
const isTreeSitterEval = log.code === "EVAL"
56+
&& log.id?.includes("web-tree-sitter");
57+
if (!isTreeSitterEval) {
58+
handler(level, log);
59+
}
60+
},
61+
output: {
62+
manualChunks: vendorChunk,
63+
},
64+
},
2665
},
2766
};
2867
});

0 commit comments

Comments
 (0)