Improve git action toast feedback

This commit is contained in:
Mohamed Boudra
2026-04-10 19:53:45 +07:00
parent a315021c75
commit e22fb51975
3 changed files with 174 additions and 83 deletions

View File

@@ -80,6 +80,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import {
formatDiffContentText,
formatDiffGutterText,
@@ -607,12 +608,12 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
@@ -919,77 +920,106 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
toastActionError(err, "Failed to merge");
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
toast.error("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
toastActionError(err, "Failed to archive worktree");
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
@@ -1458,7 +1488,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</View>
) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>

View File

@@ -47,6 +47,7 @@ export function useToastHost(): {
toast: ToastState | null;
dismiss: () => void;
} {
const { theme } = useUnistyles();
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
@@ -84,11 +85,11 @@ export function useToastHost(): {
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
variant: "success",
icon: <CheckCircle2 size={18} />,
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show],
[show, theme.colors.foreground],
);
const dismiss = useCallback(() => {
@@ -113,6 +114,8 @@ export function ToastViewport({
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissDeadlineRef = useRef<number | null>(null);
const remainingDurationRef = useRef(0);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
@@ -143,9 +146,39 @@ export function ToastViewport({
});
}, [clearTimer, onDismiss, opacity, translateY]);
const scheduleDismiss = useCallback(
(durationMs: number) => {
clearTimer();
const nextDurationMs = Math.max(0, durationMs);
remainingDurationRef.current = nextDurationMs;
dismissDeadlineRef.current = Date.now() + nextDurationMs;
timeoutRef.current = setTimeout(() => {
animateOut();
}, nextDurationMs);
},
[animateOut, clearTimer],
);
const pauseDismiss = useCallback(() => {
if (dismissDeadlineRef.current !== null) {
remainingDurationRef.current = Math.max(0, dismissDeadlineRef.current - Date.now());
}
dismissDeadlineRef.current = null;
clearTimer();
}, [clearTimer]);
const resumeDismiss = useCallback(() => {
if (!toast) {
return;
}
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
}, [scheduleDismiss, toast]);
useEffect(() => {
if (!toast) {
clearTimer();
dismissDeadlineRef.current = null;
remainingDurationRef.current = 0;
opacity.setValue(0);
translateY.setValue(-8);
return;
@@ -170,14 +203,13 @@ export function ToastViewport({
}),
]).start();
timeoutRef.current = setTimeout(() => {
animateOut();
}, toast.durationMs);
remainingDurationRef.current = toast.durationMs;
scheduleDismiss(toast.durationMs);
return () => {
clearTimer();
};
}, [animateOut, clearTimer, opacity, toast, translateY]);
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
if (!toast) {
return null;
@@ -202,6 +234,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
@@ -219,7 +253,6 @@ export function ToastViewport({
<Text
testID="app-toast-message"
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
numberOfLines={2}
>
{toast.content}
</Text>
@@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full,
borderRadius: theme.borderRadius["2xl"],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],

View File

@@ -7,6 +7,7 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -31,13 +32,12 @@ interface UseGitActionsInput {
interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
actionError: string | null;
isGit: boolean;
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const router = useRouter();
const [actionError, setActionError] = useState<string | null>(null);
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -125,78 +125,107 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
// Handlers
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
toastActionError(err, "Failed to merge");
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
toast.error("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
toastActionError(err, "Failed to archive worktree");
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
// Derived state
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
@@ -333,5 +362,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRef,
]);
return { gitActions, branchLabel, actionError, isGit };
return { gitActions, branchLabel, isGit };
}