Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 89 additions & 43 deletions apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,46 +187,24 @@ const GitExplorerComponent: React.FC = () => {
// Git
const refreshGit = useCallback(async () => {
if (!rootPath) return;
try {
const status = await invoke("get_git_status", { path: rootPath }, { silent: true });
setIsGitRepo(true);
const lines = status.split("\n").filter((l: string) => l.trim());
// Porcelain v1: XY filename
// X = staged status, Y = unstaged status
const staged: GitFileChange[] = [];
const unstaged: GitFileChange[] = [];
let conflicts = false;
for (const l of lines) {
const x = l[0]; // staged
const y = l[1]; // unstaged
const file = l.substring(3).trim();
// Unmerged/conflict codes per porcelain v1: DD AU UD UA DU AA UU.
if ((x === "U" || y === "U") || (x === "A" && y === "A") || (x === "D" && y === "D")) {
conflicts = true;
}
if (x !== " " && x !== "?") staged.push({ status: x, file });
if (y !== " " && y !== "?") unstaged.push({ status: y, file });
// Untracked files (??) go to unstaged
if (x === "?" && y === "?") unstaged.push({ status: "??", file });
}
setHasConflicts(conflicts);
setStagedChanges(staged);
setGitChanges(unstaged);
const payload = await invoke("get_git_branches", { path: rootPath });
const branchList = Array.isArray(payload?.branches) ? payload.branches : [];
setBranches(branchList);
setCurrentBranch(payload?.current ?? "");

try {
const log = await invoke("git_log", { path: rootPath, limit: logLimit }, { silent: true });
setGitLog(Array.isArray(log) ? log : []);
} catch { setGitLog([]); }
try {
const sl = await invoke("git_stash_list", { path: rootPath }, { silent: true });
setStashes(Array.isArray(sl) ? sl : []);
} catch { setStashes([]); }
} catch (err) {
const errStr = String(err).toLowerCase();
// All four sub-queries read from the same working tree and don't depend
// on each other's results, so fire them in parallel. Each one spawns its
// own `git` process on the backend and the serial chain used to dominate
// the 5 s poll cycle — measured ~150-400 ms wall time per refresh;
// concurrent dispatch brings that down to ~max(single call) instead of
// sum(all calls). `allSettled` is used so a failure in one (say
// `git_log` on a brand-new repo with no commits) doesn't drop the
// status/branches output the rest of the UI depends on.
const [statusResult, branchesResult, logResult, stashResult] = await Promise.allSettled([
invoke("get_git_status", { path: rootPath }, { silent: true }),
invoke("get_git_branches", { path: rootPath }),
invoke("git_log", { path: rootPath, limit: logLimit }, { silent: true }),
invoke("git_stash_list", { path: rootPath }, { silent: true }),
]);

if (statusResult.status === "rejected") {
const errStr = String(statusResult.reason).toLowerCase();
const isNotGitRepoError =
errStr.includes("not a git repository") ||
errStr.includes("must be run in a work tree");
Expand All @@ -245,7 +223,7 @@ const GitExplorerComponent: React.FC = () => {
}
}
} else if (!isNotGitRepoError) {
logger.error("[Git refresh error]", err);
logger.error("[Git refresh error]", statusResult.reason);
}
setIsGitRepo(false);
setStagedChanges([]);
Expand All @@ -255,7 +233,54 @@ const GitExplorerComponent: React.FC = () => {
setGitLog([]);
setStashes([]);
setHasConflicts(false);
return;
}

// Status succeeded — parse it and assume the rest should populate too.
setIsGitRepo(true);
const lines = statusResult.value.split("\n").filter((l: string) => l.trim());
// Porcelain v1: XY filename
// X = staged status, Y = unstaged status
const staged: GitFileChange[] = [];
const unstaged: GitFileChange[] = [];
let conflicts = false;
for (const l of lines) {
const x = l[0]; // staged
const y = l[1]; // unstaged
const file = l.substring(3).trim();
// Unmerged/conflict codes per porcelain v1: DD AU UD UA DU AA UU.
if ((x === "U" || y === "U") || (x === "A" && y === "A") || (x === "D" && y === "D")) {
conflicts = true;
}
if (x !== " " && x !== "?") staged.push({ status: x, file });
if (y !== " " && y !== "?") unstaged.push({ status: y, file });
// Untracked files (??) go to unstaged
if (x === "?" && y === "?") unstaged.push({ status: "??", file });
}
setHasConflicts(conflicts);
setStagedChanges(staged);
setGitChanges(unstaged);

if (branchesResult.status === "fulfilled") {
const payload = branchesResult.value;
const branchList = Array.isArray(payload?.branches) ? payload.branches : [];
setBranches(branchList);
setCurrentBranch(payload?.current ?? "");
} else {
setBranches([]);
setCurrentBranch("");
}

setGitLog(
logResult.status === "fulfilled" && Array.isArray(logResult.value)
? logResult.value
: []
);
setStashes(
stashResult.status === "fulfilled" && Array.isArray(stashResult.value)
? stashResult.value
: []
);
// Intentionally exclude `t`: re-creating `refreshGit` on every locale change would
// invalidate the `useEffect` that polls it and retrigger the whole git refresh on
// language switch. Error messages read via `t` only need to be current at the time
Expand All @@ -274,14 +299,35 @@ const GitExplorerComponent: React.FC = () => {

refreshGit();

// Poll every 5 seconds while the git tab is active and the window is visible
// Poll every 5 seconds while the git tab is active, the window is visible,
// and the app actually has focus. `hasFocus()` is stricter than
// `visibilityState`: a visible-but-unfocused window (user typing in the
// browser) still costs a `git status`+`git log`+… every 5 s today, and on
// battery that dominates idle CPU. Gating the interval also drops the
// child-process wake-ups that were firing for no visible change.
const interval = setInterval(() => {
if (document.visibilityState === "visible" && !gitLoadingRef.current) {
if (
document.visibilityState === "visible" &&
document.hasFocus() &&
!gitLoadingRef.current
) {
refreshGit();
}
}, 5000);

return () => clearInterval(interval);
// Snap back to a fresh refresh the moment the user returns, instead of
// showing up-to-5-s-stale data until the next tick.
const onFocus = () => {
if (document.visibilityState === "visible" && !gitLoadingRef.current) {
refreshGit();
}
Comment thread
matiaspalmac marked this conversation as resolved.
};
window.addEventListener("focus", onFocus);

return () => {
clearInterval(interval);
window.removeEventListener("focus", onFocus);
};
}, [activeSidebarTab, rootPath, refreshGit]);

const handleGitInit = async () => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_init", { path: rootPath }); setIsGitRepo(true); await refreshGit(); flash(t('git.status.init_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } };
Expand Down
Loading