Merge branch 'main' into dev
This commit is contained in:
@@ -14,7 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { THEME_TO_UNISTYLES } from "@/styles/theme";
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -360,6 +360,8 @@ interface AppContainerProps {
|
|||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({
|
||||||
children,
|
children,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
@@ -367,6 +369,7 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
@@ -375,6 +378,12 @@ function AppContainer({
|
|||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
|
const cycleTheme = useCallback(() => {
|
||||||
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||||
|
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||||
|
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
||||||
|
}, [settings.theme, updateSettings]);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
|
||||||
@@ -415,6 +424,7 @@ function AppContainer({
|
|||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
baseRefAvailable: true,
|
baseRefAvailable: true,
|
||||||
baseRefLabel: "main",
|
baseRefLabel: "main",
|
||||||
aheadCount: 0,
|
aheadCount: 0,
|
||||||
|
behindBaseCount: 0,
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
shouldPromoteArchive: false,
|
shouldPromoteArchive: false,
|
||||||
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -56,87 +62,99 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("git-actions-policy", () => {
|
describe("git-actions-policy", () => {
|
||||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
it("shows only remote sync actions on the base branch", () => {
|
||||||
const noPrActions = buildGitActions(createInput());
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
const withPrActions = buildGitActions(
|
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
hasPullRequest: true,
|
|
||||||
pullRequestUrl: "https://example.com/pr/123",
|
|
||||||
aheadCount: 3,
|
|
||||||
aheadOfOrigin: 2,
|
|
||||||
shipDefault: "pr",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(noPrActions.primary).toBeNull();
|
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
|
||||||
expect(withPrActions.primary?.id).toBe("push");
|
|
||||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
"merge-from-base",
|
|
||||||
"push",
|
|
||||||
]);
|
|
||||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
"merge-from-base",
|
|
||||||
"push",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables hidden-before actions with explanations instead", () => {
|
it("prioritizes pull when the branch is behind origin", () => {
|
||||||
const actions = buildGitActions(createInput());
|
|
||||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
|
||||||
|
|
||||||
expect(actionById.get("push")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No remote configured",
|
|
||||||
});
|
|
||||||
expect(actionById.get("pr")).toMatchObject({
|
|
||||||
label: "Create PR",
|
|
||||||
disabled: true,
|
|
||||||
description: "Branch has no commits ahead of main",
|
|
||||||
});
|
|
||||||
expect(actionById.get("merge-branch")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No commits to merge into main",
|
|
||||||
});
|
|
||||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
|
||||||
disabled: true,
|
|
||||||
description: "No remote configured",
|
|
||||||
});
|
|
||||||
expect(actionById.has("archive-worktree")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the current primary action visible in the menu", () => {
|
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
|
behindOfOrigin: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
aheadOfOrigin: 1,
|
||||||
|
behindOfOrigin: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const pushAction = actions.secondary.find((action) => action.id === "push");
|
||||||
|
|
||||||
|
expect(pushAction).toMatchObject({
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: "Push isn't available yet because there are newer changes to bring in first",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows update-from-base only on feature branches that are behind the base branch", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
behindBaseCount: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||||
|
|
||||||
|
expect(updateAction).toMatchObject({
|
||||||
|
label: "Update from main",
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a clear sentence when pull is unavailable", () => {
|
||||||
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
|
const pullAction = actions.secondary.find((action) => action.id === "pull");
|
||||||
|
|
||||||
|
expect(pullAction).toMatchObject({
|
||||||
|
disabled: false,
|
||||||
|
unavailableMessage: "Pull isn't available because this branch is already up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps update-from-base off the base branch entirely", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
behindOfOrigin: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps feature branch actions available off the base branch", () => {
|
||||||
|
const actions = buildGitActions(
|
||||||
|
createInput({
|
||||||
|
hasRemote: true,
|
||||||
|
isOnBaseBranch: false,
|
||||||
|
aheadCount: 2,
|
||||||
|
behindBaseCount: 1,
|
||||||
hasPullRequest: true,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actions.primary?.id).toBe("pr");
|
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||||
expect(
|
"pull",
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
"push",
|
||||||
).toBe(true);
|
"merge-from-base",
|
||||||
});
|
"merge-branch",
|
||||||
|
"pr",
|
||||||
it("disables sync on the base branch when already up to date", () => {
|
]);
|
||||||
const actions = buildGitActions(
|
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(
|
||||||
createInput({
|
true,
|
||||||
hasRemote: true,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
|
||||||
|
|
||||||
expect(syncAction).toMatchObject({
|
|
||||||
label: "Sync",
|
|
||||||
disabled: true,
|
|
||||||
description: "Already up to date",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only shows archive worktree for paseo worktrees", () => {
|
it("only shows archive worktree for paseo worktrees", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
|||||||
|
|
||||||
export type GitActionId =
|
export type GitActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
|
| "pull"
|
||||||
| "push"
|
| "push"
|
||||||
| "pr"
|
| "pr"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
@@ -17,7 +18,7 @@ export interface GitAction {
|
|||||||
successLabel: string;
|
successLabel: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
status: ActionStatus;
|
status: ActionStatus;
|
||||||
description?: string;
|
unavailableMessage?: string;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
handler: () => void;
|
handler: () => void;
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
|
|||||||
baseRefAvailable: boolean;
|
baseRefAvailable: boolean;
|
||||||
baseRefLabel: string;
|
baseRefLabel: string;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
|
behindBaseCount: number;
|
||||||
aheadOfOrigin: number;
|
aheadOfOrigin: number;
|
||||||
behindOfOrigin: number;
|
behindOfOrigin: number;
|
||||||
shouldPromoteArchive: boolean;
|
shouldPromoteArchive: boolean;
|
||||||
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
|
||||||
|
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
|
||||||
|
|
||||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||||
if (!input.isGit) {
|
if (!input.isGit) {
|
||||||
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime.commit.handler,
|
handler: input.runtime.commit.handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
allActions.set("pull", {
|
||||||
|
id: "pull",
|
||||||
|
label: "Pull",
|
||||||
|
pendingLabel: "Pulling...",
|
||||||
|
successLabel: "Pulled",
|
||||||
|
disabled: input.runtime.pull.disabled,
|
||||||
|
status: input.runtime.pull.status,
|
||||||
|
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
|
||||||
|
icon: input.runtime.pull.icon,
|
||||||
|
handler: input.runtime.pull.handler,
|
||||||
|
});
|
||||||
|
|
||||||
allActions.set("push", {
|
allActions.set("push", {
|
||||||
id: "push",
|
id: "push",
|
||||||
label: "Push",
|
label: "Push",
|
||||||
pendingLabel: "Pushing...",
|
pendingLabel: "Pushing...",
|
||||||
successLabel: "Pushed",
|
successLabel: "Pushed",
|
||||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
disabled: input.runtime.push.disabled,
|
||||||
status: input.runtime.push.status,
|
status: input.runtime.push.status,
|
||||||
description: input.hasRemote ? undefined : "No remote configured",
|
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
|
||||||
icon: input.runtime.push.icon,
|
icon: input.runtime.push.icon,
|
||||||
handler: input.runtime.push.handler,
|
handler: input.runtime.push.handler,
|
||||||
});
|
});
|
||||||
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: `Merge into ${input.baseRefLabel}`,
|
label: `Merge into ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Merging...",
|
pendingLabel: "Merging...",
|
||||||
successLabel: "Merged",
|
successLabel: "Merged",
|
||||||
disabled:
|
disabled: input.runtime["merge-branch"].disabled,
|
||||||
input.runtime["merge-branch"].disabled ||
|
|
||||||
!input.baseRefAvailable ||
|
|
||||||
input.hasUncommittedChanges ||
|
|
||||||
input.aheadCount === 0,
|
|
||||||
status: input.runtime["merge-branch"].status,
|
status: input.runtime["merge-branch"].status,
|
||||||
description: getMergeBranchDescription(input),
|
unavailableMessage:
|
||||||
|
input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
|
||||||
icon: input.runtime["merge-branch"].icon,
|
icon: input.runtime["merge-branch"].icon,
|
||||||
handler: input.runtime["merge-branch"].handler,
|
handler: input.runtime["merge-branch"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
label: `Update from ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Updating...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
successLabel: "Updated",
|
||||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
disabled: input.runtime["merge-from-base"].disabled,
|
||||||
status: input.runtime["merge-from-base"].status,
|
status: input.runtime["merge-from-base"].status,
|
||||||
description: getMergeFromBaseDescription(input),
|
unavailableMessage:
|
||||||
|
input.runtime["merge-from-base"].disabled
|
||||||
|
? undefined
|
||||||
|
: getMergeFromBaseUnavailableMessage(input),
|
||||||
icon: input.runtime["merge-from-base"].icon,
|
icon: input.runtime["merge-from-base"].icon,
|
||||||
handler: input.runtime["merge-from-base"].handler,
|
handler: input.runtime["merge-from-base"].handler,
|
||||||
});
|
});
|
||||||
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: "Archive worktree",
|
label: "Archive worktree",
|
||||||
pendingLabel: "Archiving...",
|
pendingLabel: "Archiving...",
|
||||||
successLabel: "Archived",
|
successLabel: "Archived",
|
||||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
disabled: input.runtime["archive-worktree"].disabled,
|
||||||
status: input.runtime["archive-worktree"].status,
|
status: input.runtime["archive-worktree"].status,
|
||||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
unavailableMessage:
|
||||||
|
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||||
|
? undefined
|
||||||
|
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||||
icon: input.runtime["archive-worktree"].icon,
|
icon: input.runtime["archive-worktree"].icon,
|
||||||
handler: input.runtime["archive-worktree"].handler,
|
handler: input.runtime["archive-worktree"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
const primaryActionId = getPrimaryActionId(input);
|
||||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
||||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
|
||||||
|
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||||
|
if (!input.isOnBaseBranch) {
|
||||||
|
secondaryIds.push(...FEATURE_ACTION_IDS);
|
||||||
|
}
|
||||||
if (input.isPaseoOwnedWorktree) {
|
if (input.isPaseoOwnedWorktree) {
|
||||||
secondary.push(allActions.get("archive-worktree")!);
|
secondaryIds.push("archive-worktree");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { primary, secondary, menu: [] };
|
return {
|
||||||
|
primary,
|
||||||
|
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||||
|
menu: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||||
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "commit";
|
return "commit";
|
||||||
}
|
}
|
||||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
if (canPull(input)) {
|
||||||
|
return "pull";
|
||||||
|
}
|
||||||
|
if (canPush(input)) {
|
||||||
return "push";
|
return "push";
|
||||||
}
|
}
|
||||||
|
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||||
|
return "merge-from-base";
|
||||||
|
}
|
||||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return "pr";
|
return "pr";
|
||||||
}
|
}
|
||||||
if (
|
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||||
input.isOnBaseBranch &&
|
|
||||||
input.hasRemote &&
|
|
||||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
|
||||||
) {
|
|
||||||
return "merge-from-base";
|
|
||||||
}
|
|
||||||
if (input.aheadCount > 0) {
|
|
||||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "View PR",
|
label: "View PR",
|
||||||
pendingLabel: "View PR",
|
pendingLabel: "View PR",
|
||||||
successLabel: "View PR",
|
successLabel: "View PR",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
unavailableMessage:
|
||||||
|
input.runtime.pr.disabled || input.githubFeaturesEnabled
|
||||||
|
? undefined
|
||||||
|
: "View PR isn't available right now because GitHub isn't connected",
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
@@ -184,65 +212,103 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
pendingLabel: "Creating PR...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: getCreatePrDescription(input),
|
unavailableMessage:
|
||||||
|
input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canPull(input: BuildGitActionsInput): boolean {
|
||||||
|
return (
|
||||||
|
input.hasRemote &&
|
||||||
|
!input.hasUncommittedChanges &&
|
||||||
|
input.behindOfOrigin > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPush(input: BuildGitActionsInput): boolean {
|
||||||
|
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canMergeBranch(input: BuildGitActionsInput): boolean {
|
||||||
|
return (
|
||||||
|
!input.isOnBaseBranch &&
|
||||||
|
input.baseRefAvailable &&
|
||||||
|
!input.hasUncommittedChanges &&
|
||||||
|
input.aheadCount > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
return (
|
||||||
return false;
|
!input.isOnBaseBranch &&
|
||||||
}
|
input.baseRefAvailable &&
|
||||||
if (!input.isOnBaseBranch) {
|
!input.hasUncommittedChanges &&
|
||||||
return true;
|
input.behindBaseCount > 0
|
||||||
}
|
);
|
||||||
if (!input.hasRemote) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return "Pull isn't available here because this branch is not connected to a remote yet";
|
||||||
|
}
|
||||||
|
if (input.hasUncommittedChanges) {
|
||||||
|
return "Pull isn't available while you have local changes so commit or stash them first";
|
||||||
|
}
|
||||||
|
if (input.behindOfOrigin === 0) {
|
||||||
|
return "Pull isn't available because this branch is already up to date";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
|
if (!input.hasRemote) {
|
||||||
|
return "Push isn't available here because this branch is not connected to a remote yet";
|
||||||
|
}
|
||||||
|
if (input.behindOfOrigin > 0) {
|
||||||
|
return "Push isn't available yet because there are newer changes to bring in first";
|
||||||
|
}
|
||||||
|
if (input.aheadOfOrigin === 0) {
|
||||||
|
return "Push isn't available because there is nothing new to send";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.githubFeaturesEnabled) {
|
if (!input.githubFeaturesEnabled) {
|
||||||
return "GitHub features unavailable";
|
return "Create PR isn't available right now because GitHub isn't connected";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
return "Create PR isn't available because this branch doesn't have any new commits yet";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Merge isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Requires clean working tree";
|
return "Merge isn't available while you have local changes so commit or stash them first";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
if (input.aheadCount === 0) {
|
||||||
return `No commits to merge into ${input.baseRefLabel}`;
|
return "Merge isn't available because this branch doesn't have anything new to merge yet";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Update isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "Requires clean working tree";
|
return "Update isn't available while you have local changes so commit or stash them first";
|
||||||
}
|
}
|
||||||
if (!input.isOnBaseBranch) {
|
if (input.behindBaseCount === 0) {
|
||||||
return undefined;
|
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
|
||||||
}
|
|
||||||
if (!input.hasRemote) {
|
|
||||||
return "No remote configured";
|
|
||||||
}
|
|
||||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
|
||||||
return "Already up to date";
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
|
|||||||
|
|
||||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
|
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
return action.label;
|
return action.label;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleActionSelect = useCallback(
|
||||||
|
(action: GitAction) => {
|
||||||
|
if (action.unavailableMessage) {
|
||||||
|
toast.show(action.unavailableMessage, {
|
||||||
|
durationMs: 3200,
|
||||||
|
icon: <Info size={16} color={theme.colors.foreground} />,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action.handler();
|
||||||
|
},
|
||||||
|
[theme.colors.foreground, toast],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{gitActions.primary ? (
|
{gitActions.primary ? (
|
||||||
@@ -73,7 +89,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||||
{gitActions.secondary.map((action, index) => {
|
{gitActions.secondary.map((action, index) => {
|
||||||
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
const needsSeparator =
|
||||||
|
action.id === "merge-from-base" || action.id === "archive-worktree";
|
||||||
return (
|
return (
|
||||||
<View key={action.id}>
|
<View key={action.id}>
|
||||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
@@ -86,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
|
muted={Boolean(action.unavailableMessage)}
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
@@ -94,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
action.id === "pr" &&
|
action.id === "pr" &&
|
||||||
action.label === "View PR"
|
action.label === "View PR"
|
||||||
}
|
}
|
||||||
description={action.description}
|
onSelect={() => handleActionSelect(action)}
|
||||||
onSelect={action.handler}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -125,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
testID={`changes-menu-${action.id}`}
|
testID={`changes-menu-${action.id}`}
|
||||||
leading={action.icon}
|
leading={action.icon}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
|
muted={Boolean(action.unavailableMessage)}
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
onSelect={action.handler}
|
onSelect={() => handleActionSelect(action)}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
|
Download,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommitHorizontal,
|
GitCommitHorizontal,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
@@ -81,6 +82,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import {
|
import {
|
||||||
formatDiffContentText,
|
formatDiffContentText,
|
||||||
formatDiffGutterText,
|
formatDiffGutterText,
|
||||||
@@ -607,12 +609,12 @@ type DiffFlatItem =
|
|||||||
|
|
||||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||||
@@ -896,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||||
);
|
);
|
||||||
|
const pullStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||||
|
);
|
||||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||||
);
|
);
|
||||||
@@ -913,83 +918,123 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||||
|
const runPull = useCheckoutGitActionsStore((state) => state.pull);
|
||||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
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(() => {
|
const handleCommit = useCallback(() => {
|
||||||
setActionError(null);
|
void runCommit({ serverId, cwd })
|
||||||
void runCommit({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
toastActionSuccess("Committed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runCommit, serverId, cwd]);
|
toastActionError(err, "Failed to commit");
|
||||||
|
});
|
||||||
|
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
|
const handlePull = useCallback(() => {
|
||||||
|
void runPull({ serverId, cwd })
|
||||||
|
.then(() => {
|
||||||
|
toastActionSuccess("Pulled");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toastActionError(err, "Failed to pull");
|
||||||
|
});
|
||||||
|
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handlePush = useCallback(() => {
|
const handlePush = useCallback(() => {
|
||||||
setActionError(null);
|
void runPush({ serverId, cwd })
|
||||||
void runPush({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to push";
|
toastActionSuccess("Pushed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runPush, serverId, cwd]);
|
toastActionError(err, "Failed to push");
|
||||||
|
});
|
||||||
|
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleCreatePr = useCallback(() => {
|
const handleCreatePr = useCallback(() => {
|
||||||
void persistShipDefault("pr");
|
void persistShipDefault("pr");
|
||||||
setActionError(null);
|
void runCreatePr({ serverId, cwd })
|
||||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
toastActionSuccess("PR created");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
toastActionError(err, "Failed to create PR");
|
||||||
|
});
|
||||||
|
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleMergeBranch = useCallback(() => {
|
const handleMergeBranch = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void persistShipDefault("merge");
|
void persistShipDefault("merge");
|
||||||
setActionError(null);
|
|
||||||
void runMergeBranch({ serverId, cwd, baseRef })
|
void runMergeBranch({ serverId, cwd, baseRef })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setPostShipArchiveSuggested(true);
|
setPostShipArchiveSuggested(true);
|
||||||
|
toastActionSuccess("Merged");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
toastActionError(err, "Failed to merge");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
}, [
|
||||||
|
baseRef,
|
||||||
|
cwd,
|
||||||
|
persistShipDefault,
|
||||||
|
runMergeBranch,
|
||||||
|
serverId,
|
||||||
|
toast,
|
||||||
|
toastActionError,
|
||||||
|
toastActionSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleMergeFromBase = useCallback(() => {
|
const handleMergeFromBase = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
toastActionSuccess("Updated");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
toastActionError(err, "Failed to merge from base");
|
||||||
|
});
|
||||||
|
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
const worktreePath = status?.cwd;
|
const worktreePath = status?.cwd;
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
setActionError("Worktree path unavailable");
|
toast.error("Worktree path unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
|
||||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
toastActionError(err, "Failed to archive worktree");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||||
|
|
||||||
const renderFlatItem = useCallback(
|
const renderFlatItem = useCallback(
|
||||||
({ item }: { item: DiffFlatItem }) => {
|
({ item }: { item: DiffFlatItem }) => {
|
||||||
@@ -1041,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
: "Unknown";
|
: "Unknown";
|
||||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||||
|
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
|
||||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||||
const baseRefLabel = useMemo(() => {
|
const baseRefLabel = useMemo(() => {
|
||||||
@@ -1066,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
(postShipArchiveSuggested || isMergedPullRequest);
|
(postShipArchiveSuggested || isMergedPullRequest);
|
||||||
|
|
||||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
|
const pullDisabled = actionsDisabled || pullStatus === "pending";
|
||||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
||||||
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
||||||
@@ -1172,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
baseRefAvailable: Boolean(baseRef),
|
baseRefAvailable: Boolean(baseRef),
|
||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
@@ -1183,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||||
handler: handleCommit,
|
handler: handleCommit,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: pullDisabled,
|
||||||
|
status: pullStatus,
|
||||||
|
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
handler: handlePull,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: pushDisabled,
|
disabled: pushDisabled,
|
||||||
status: pushStatus,
|
status: pushStatus,
|
||||||
@@ -1227,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
hasPullRequest,
|
hasPullRequest,
|
||||||
prStatus?.url,
|
prStatus?.url,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch,
|
isOnBaseBranch,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
@@ -1237,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
commitDisabled,
|
commitDisabled,
|
||||||
|
pullDisabled,
|
||||||
pushDisabled,
|
pushDisabled,
|
||||||
prDisabled,
|
prDisabled,
|
||||||
mergeDisabled,
|
mergeDisabled,
|
||||||
mergeFromBaseDisabled,
|
mergeFromBaseDisabled,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
commitStatus,
|
commitStatus,
|
||||||
|
pullStatus,
|
||||||
pushStatus,
|
pushStatus,
|
||||||
prCreateStatus,
|
prCreateStatus,
|
||||||
mergeStatus,
|
mergeStatus,
|
||||||
mergeFromBaseStatus,
|
mergeFromBaseStatus,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
handleCommit,
|
handleCommit,
|
||||||
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleCreatePr,
|
handleCreatePr,
|
||||||
handleMergeBranch,
|
handleMergeBranch,
|
||||||
@@ -1458,7 +1516,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
|
||||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||||
|
|
||||||
<View style={styles.diffContainer}>
|
<View style={styles.diffContainer}>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useSyncExternalStore,
|
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
type ReactElement,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -44,8 +44,8 @@ import {
|
|||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -69,18 +69,12 @@ interface LeftSidebarProps {
|
|||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostOption {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
activeServerId: string | null;
|
activeServerId: string | null;
|
||||||
activeHostLabel: string;
|
activeHostLabel: string;
|
||||||
activeHostStatusColor: string;
|
activeHostStatusColor: string;
|
||||||
hostOptions: HostOption[];
|
hostOptions: ComboboxOption[];
|
||||||
hostTriggerRef: RefObject<View | null>;
|
hostTriggerRef: RefObject<View | null>;
|
||||||
isHostPickerOpen: boolean;
|
isHostPickerOpen: boolean;
|
||||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
@@ -95,6 +89,12 @@ interface SidebarSharedProps {
|
|||||||
handleHostSelect: (nextServerId: string) => void;
|
handleHostSelect: (nextServerId: string) => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
|
renderHostOption: (input: {
|
||||||
|
option: ComboboxOption;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
@@ -124,28 +124,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
|
||||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
|
||||||
() =>
|
|
||||||
daemons
|
|
||||||
.map(
|
|
||||||
(daemon) =>
|
|
||||||
`${daemon.serverId}:${
|
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
.join("|"),
|
|
||||||
() =>
|
|
||||||
daemons
|
|
||||||
.map(
|
|
||||||
(daemon) =>
|
|
||||||
`${daemon.serverId}:${
|
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
.join("|"),
|
|
||||||
);
|
|
||||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const activeDaemon = useMemo(() => {
|
const activeDaemon = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
if (daemons.length === 0) {
|
||||||
@@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const trimmed = activeDaemon.label?.trim();
|
const trimmed = activeDaemon.label?.trim();
|
||||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||||
}, [activeDaemon]);
|
}, [activeDaemon]);
|
||||||
|
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||||
const activeHostStatus = activeServerId
|
const activeHostStatus = activeServerId
|
||||||
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
|
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||||
: "idle";
|
: "idle";
|
||||||
const activeHostStatusColor =
|
const activeHostStatusColor =
|
||||||
activeHostStatus === "online"
|
activeHostStatus === "online"
|
||||||
@@ -179,11 +158,25 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
daemons.map((daemon) => ({
|
daemons.map((daemon) => ({
|
||||||
id: daemon.serverId,
|
id: daemon.serverId,
|
||||||
label: daemon.label?.trim() || daemon.serverId,
|
label: daemon.label?.trim() || daemon.serverId,
|
||||||
description: formatConnectionStatus(
|
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
[daemons, runtime, runtimeConnectionStatusSignature],
|
[daemons],
|
||||||
|
);
|
||||||
|
const renderHostOption = useCallback(
|
||||||
|
({ option, selected, active, onPress }: {
|
||||||
|
option: ComboboxOption;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<HostSwitchOption
|
||||||
|
serverId={option.id}
|
||||||
|
label={option.label}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
@@ -276,6 +269,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
@@ -305,6 +299,33 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function HostSwitchOption({
|
||||||
|
serverId,
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label={label}
|
||||||
|
description={formatConnectionStatus(connectionStatus)}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SessionsButton({ onPress }: { onPress: () => void }) {
|
function SessionsButton({ onPress }: { onPress: () => void }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -361,6 +382,7 @@ function MobileSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
@@ -606,6 +628,7 @@ function MobileSidebar({
|
|||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ""}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
|
renderOption={renderHostOption}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
searchPlaceholder="Search hosts..."
|
searchPlaceholder="Search hosts..."
|
||||||
@@ -639,6 +662,7 @@ function DesktopSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
@@ -789,6 +813,7 @@ function DesktopSidebar({
|
|||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ""}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
|
renderOption={renderHostOption}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
searchPlaceholder="Search hosts..."
|
searchPlaceholder="Search hosts..."
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function useToastHost(): {
|
|||||||
toast: ToastState | null;
|
toast: ToastState | null;
|
||||||
dismiss: () => void;
|
dismiss: () => void;
|
||||||
} {
|
} {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const [toast, setToast] = useState<ToastState | null>(null);
|
const [toast, setToast] = useState<ToastState | null>(null);
|
||||||
const idRef = useRef(0);
|
const idRef = useRef(0);
|
||||||
|
|
||||||
@@ -84,11 +85,11 @@ export function useToastHost(): {
|
|||||||
copied: (label?: string) =>
|
copied: (label?: string) =>
|
||||||
show(label ? `Copied ${label}` : "Copied", {
|
show(label ? `Copied ${label}` : "Copied", {
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: <CheckCircle2 size={18} />,
|
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
|
||||||
}),
|
}),
|
||||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||||
}),
|
}),
|
||||||
[show],
|
[show, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
@@ -113,6 +114,8 @@ export function ToastViewport({
|
|||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(-8)).current;
|
const translateY = useRef(new Animated.Value(-8)).current;
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const dismissDeadlineRef = useRef<number | null>(null);
|
||||||
|
const remainingDurationRef = useRef(0);
|
||||||
|
|
||||||
const clearTimer = useCallback(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
@@ -143,9 +146,39 @@ export function ToastViewport({
|
|||||||
});
|
});
|
||||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!toast) {
|
if (!toast) {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
dismissDeadlineRef.current = null;
|
||||||
|
remainingDurationRef.current = 0;
|
||||||
opacity.setValue(0);
|
opacity.setValue(0);
|
||||||
translateY.setValue(-8);
|
translateY.setValue(-8);
|
||||||
return;
|
return;
|
||||||
@@ -170,14 +203,13 @@ export function ToastViewport({
|
|||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
remainingDurationRef.current = toast.durationMs;
|
||||||
animateOut();
|
scheduleDismiss(toast.durationMs);
|
||||||
}, toast.durationMs);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
};
|
};
|
||||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
|
||||||
|
|
||||||
if (!toast) {
|
if (!toast) {
|
||||||
return null;
|
return null;
|
||||||
@@ -202,6 +234,8 @@ export function ToastViewport({
|
|||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
testID={toast.testID ?? "app-toast"}
|
testID={toast.testID ?? "app-toast"}
|
||||||
|
onPointerEnter={pauseDismiss}
|
||||||
|
onPointerLeave={resumeDismiss}
|
||||||
style={[
|
style={[
|
||||||
styles.toast,
|
styles.toast,
|
||||||
toast.variant === "success" ? styles.toastSuccess : null,
|
toast.variant === "success" ? styles.toastSuccess : null,
|
||||||
@@ -219,7 +253,6 @@ export function ToastViewport({
|
|||||||
<Text
|
<Text
|
||||||
testID="app-toast-message"
|
testID="app-toast-message"
|
||||||
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
|
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
|
||||||
numberOfLines={2}
|
|
||||||
>
|
>
|
||||||
{toast.content}
|
{toast.content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ export function DropdownMenuItem({
|
|||||||
description,
|
description,
|
||||||
onSelect,
|
onSelect,
|
||||||
disabled,
|
disabled,
|
||||||
|
muted = false,
|
||||||
destructive,
|
destructive,
|
||||||
selected,
|
selected,
|
||||||
showSelectedCheck = false,
|
showSelectedCheck = false,
|
||||||
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
|
|||||||
description?: string;
|
description?: string;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
showSelectedCheck?: boolean;
|
showSelectedCheck?: boolean;
|
||||||
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
|
|||||||
? styles.itemSelectedInteractive
|
? styles.itemSelectedInteractive
|
||||||
: null,
|
: null,
|
||||||
isDisabled ? styles.itemDisabled : null,
|
isDisabled ? styles.itemDisabled : null,
|
||||||
|
muted && !isDisabled ? styles.itemMuted : null,
|
||||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||||
pressed && !isDisabled ? styles.itemPressed : null,
|
pressed && !isDisabled ? styles.itemPressed : null,
|
||||||
]}
|
]}
|
||||||
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
|
|||||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||||
isSuccess ? styles.itemTextSuccess : null,
|
isSuccess ? styles.itemTextSuccess : null,
|
||||||
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||||
|
muted && !isDisabled ? styles.itemTextMuted : null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
itemDisabled: {
|
itemDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
itemMuted: {
|
||||||
|
opacity: 0.72,
|
||||||
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
itemTextMuted: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
itemTextDestructive: {
|
itemTextDestructive: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||||
|
type DesktopBadgeWorkspaceStatus,
|
||||||
|
} from "@/utils/desktop-badge-state";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
type ColorScheme = "dark" | "light";
|
type ColorScheme = "dark" | "light";
|
||||||
@@ -37,21 +43,6 @@ function deriveFaviconStatus(
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveMacDockBadgeCount(
|
|
||||||
agents: ReturnType<typeof useAggregatedAgents>["agents"],
|
|
||||||
): number | undefined {
|
|
||||||
const attentionCount = agents.filter(
|
|
||||||
(agent) =>
|
|
||||||
(agent.pendingPermissionCount ?? 0) > 0 ||
|
|
||||||
(agent.requiresAttention && agent.attentionReason === "finished"),
|
|
||||||
).length;
|
|
||||||
if (attentionCount > 0) {
|
|
||||||
return attentionCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||||
const image = FAVICON_IMAGES[colorScheme][status];
|
const image = FAVICON_IMAGES[colorScheme][status];
|
||||||
if (typeof image === "object" && "uri" in image) {
|
if (typeof image === "object" && "uri" in image) {
|
||||||
@@ -112,6 +103,17 @@ async function updateMacDockBadge(count?: number) {
|
|||||||
|
|
||||||
export function useFaviconStatus() {
|
export function useFaviconStatus() {
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
|
const workspaceStatuses = useSessionStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
||||||
|
for (const session of Object.values(state.sessions)) {
|
||||||
|
for (const workspace of session.workspaces.values()) {
|
||||||
|
statuses.push(workspace.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
@@ -135,10 +137,10 @@ export function useFaviconStatus() {
|
|||||||
const status = deriveFaviconStatus(agents);
|
const status = deriveFaviconStatus(agents);
|
||||||
updateFavicon(status, colorScheme);
|
updateFavicon(status, colorScheme);
|
||||||
|
|
||||||
const dockBadgeCount = deriveMacDockBadgeCount(agents);
|
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
|
||||||
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||||
void updateMacDockBadge(dockBadgeCount);
|
void updateMacDockBadge(dockBadgeCount);
|
||||||
}
|
}
|
||||||
}, [agents, colorScheme]);
|
}, [agents, colorScheme, workspaceStatuses]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
|||||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface UseGitActionsInput {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
icons: {
|
icons: {
|
||||||
commit: ReactElement;
|
commit: ReactElement;
|
||||||
|
pull: ReactElement;
|
||||||
push: ReactElement;
|
push: ReactElement;
|
||||||
viewPr: ReactElement;
|
viewPr: ReactElement;
|
||||||
createPr: ReactElement;
|
createPr: ReactElement;
|
||||||
@@ -31,13 +33,12 @@ interface UseGitActionsInput {
|
|||||||
interface UseGitActionsResult {
|
interface UseGitActionsResult {
|
||||||
gitActions: GitActions;
|
gitActions: GitActions;
|
||||||
branchLabel: string;
|
branchLabel: string;
|
||||||
actionError: string | null;
|
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const toast = useToast();
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
|
|
||||||
@@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||||
);
|
);
|
||||||
|
const pullStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||||
|
);
|
||||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||||
);
|
);
|
||||||
@@ -119,88 +123,129 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||||
|
const runPull = useCheckoutGitActionsStore((state) => state.pull);
|
||||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
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
|
// Handlers
|
||||||
const handleCommit = useCallback(() => {
|
const handleCommit = useCallback(() => {
|
||||||
setActionError(null);
|
void runCommit({ serverId, cwd })
|
||||||
void runCommit({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
toastActionSuccess("Committed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runCommit, serverId, cwd]);
|
toastActionError(err, "Failed to commit");
|
||||||
|
});
|
||||||
|
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
|
const handlePull = useCallback(() => {
|
||||||
|
void runPull({ serverId, cwd })
|
||||||
|
.then(() => {
|
||||||
|
toastActionSuccess("Pulled");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toastActionError(err, "Failed to pull");
|
||||||
|
});
|
||||||
|
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handlePush = useCallback(() => {
|
const handlePush = useCallback(() => {
|
||||||
setActionError(null);
|
void runPush({ serverId, cwd })
|
||||||
void runPush({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to push";
|
toastActionSuccess("Pushed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runPush, serverId, cwd]);
|
toastActionError(err, "Failed to push");
|
||||||
|
});
|
||||||
|
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleCreatePr = useCallback(() => {
|
const handleCreatePr = useCallback(() => {
|
||||||
void persistShipDefault("pr");
|
void persistShipDefault("pr");
|
||||||
setActionError(null);
|
void runCreatePr({ serverId, cwd })
|
||||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
toastActionSuccess("PR created");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
toastActionError(err, "Failed to create PR");
|
||||||
|
});
|
||||||
|
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleMergeBranch = useCallback(() => {
|
const handleMergeBranch = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void persistShipDefault("merge");
|
void persistShipDefault("merge");
|
||||||
setActionError(null);
|
|
||||||
void runMergeBranch({ serverId, cwd, baseRef })
|
void runMergeBranch({ serverId, cwd, baseRef })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setPostShipArchiveSuggested(true);
|
setPostShipArchiveSuggested(true);
|
||||||
|
toastActionSuccess("Merged");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
toastActionError(err, "Failed to merge");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
}, [
|
||||||
|
baseRef,
|
||||||
|
cwd,
|
||||||
|
persistShipDefault,
|
||||||
|
runMergeBranch,
|
||||||
|
serverId,
|
||||||
|
toast,
|
||||||
|
toastActionError,
|
||||||
|
toastActionSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleMergeFromBase = useCallback(() => {
|
const handleMergeFromBase = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
toastActionSuccess("Updated");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
toastActionError(err, "Failed to merge from base");
|
||||||
|
});
|
||||||
|
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
const worktreePath = status?.cwd;
|
const worktreePath = status?.cwd;
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
setActionError("Worktree path unavailable");
|
toast.error("Worktree path unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
|
||||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
toastActionError(err, "Failed to archive worktree");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||||
|
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
|
||||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||||
const baseRefLabel = useMemo(() => {
|
const baseRefLabel = useMemo(() => {
|
||||||
@@ -220,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
(postShipArchiveSuggested || isMergedPullRequest);
|
(postShipArchiveSuggested || isMergedPullRequest);
|
||||||
|
|
||||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
|
const pullDisabled = actionsDisabled || pullStatus === "pending";
|
||||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
||||||
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
||||||
@@ -247,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
baseRefAvailable: Boolean(baseRef),
|
baseRefAvailable: Boolean(baseRef),
|
||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
@@ -258,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
icon: icons.commit,
|
icon: icons.commit,
|
||||||
handler: handleCommit,
|
handler: handleCommit,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: pullDisabled,
|
||||||
|
status: pullStatus,
|
||||||
|
icon: icons.pull,
|
||||||
|
handler: handlePull,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: pushDisabled,
|
disabled: pushDisabled,
|
||||||
status: pushStatus,
|
status: pushStatus,
|
||||||
@@ -302,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
hasPullRequest,
|
hasPullRequest,
|
||||||
prStatus?.url,
|
prStatus?.url,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch,
|
isOnBaseBranch,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
@@ -312,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
commitDisabled,
|
commitDisabled,
|
||||||
|
pullDisabled,
|
||||||
pushDisabled,
|
pushDisabled,
|
||||||
prDisabled,
|
prDisabled,
|
||||||
mergeDisabled,
|
mergeDisabled,
|
||||||
mergeFromBaseDisabled,
|
mergeFromBaseDisabled,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
commitStatus,
|
commitStatus,
|
||||||
|
pullStatus,
|
||||||
pushStatus,
|
pushStatus,
|
||||||
prCreateStatus,
|
prCreateStatus,
|
||||||
mergeStatus,
|
mergeStatus,
|
||||||
mergeFromBaseStatus,
|
mergeFromBaseStatus,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
handleCommit,
|
handleCommit,
|
||||||
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleCreatePr,
|
handleCreatePr,
|
||||||
handleMergeBranch,
|
handleMergeBranch,
|
||||||
@@ -333,5 +390,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
baseRef,
|
baseRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { gitActions, branchLabel, actionError, isGit };
|
return { gitActions, branchLabel, isGit };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function useKeyboardShortcuts({
|
|||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
cycleTheme,
|
||||||
}: {
|
}: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -43,6 +44,7 @@ export function useKeyboardShortcuts({
|
|||||||
toggleFileExplorer?: () => void;
|
toggleFileExplorer?: () => void;
|
||||||
toggleBothSidebars?: () => void;
|
toggleBothSidebars?: () => void;
|
||||||
toggleFocusMode?: () => void;
|
toggleFocusMode?: () => void;
|
||||||
|
cycleTheme?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
@@ -267,6 +269,11 @@ export function useKeyboardShortcuts({
|
|||||||
toggleFocusMode();
|
toggleFocusMode();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
case "theme.cycle":
|
||||||
|
if (cycleTheme) {
|
||||||
|
cycleTheme();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
case "command-center.toggle": {
|
case "command-center.toggle": {
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
const store = useKeyboardShortcutsStore.getState();
|
||||||
if (!store.commandCenterOpen) {
|
if (!store.commandCenterOpen) {
|
||||||
@@ -413,6 +420,7 @@ export function useKeyboardShortcuts({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
bindings,
|
bindings,
|
||||||
|
cycleTheme,
|
||||||
enabled,
|
enabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
openProjectPickerAction,
|
openProjectPickerAction,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type KeyboardActionId =
|
|||||||
| "worktree.new"
|
| "worktree.new"
|
||||||
| "worktree.archive"
|
| "worktree.archive"
|
||||||
| "view.toggle.focus"
|
| "view.toggle.focus"
|
||||||
|
| "theme.cycle"
|
||||||
| "message-input.action";
|
| "message-input.action";
|
||||||
|
|
||||||
export type KeyboardShortcutPayload =
|
export type KeyboardShortcutPayload =
|
||||||
|
|||||||
@@ -744,6 +744,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Theme cycling ---
|
||||||
|
{
|
||||||
|
id: "theme-cycle-cmd-shift-t-mac",
|
||||||
|
action: "theme.cycle",
|
||||||
|
combo: "Cmd+Alt+T",
|
||||||
|
when: { mac: true, commandCenter: false },
|
||||||
|
help: {
|
||||||
|
id: "cycle-theme",
|
||||||
|
section: "panels",
|
||||||
|
label: "Cycle theme",
|
||||||
|
keys: ["mod", "alt", "T"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "theme-cycle-ctrl-alt-t-non-mac",
|
||||||
|
action: "theme.cycle",
|
||||||
|
combo: "Ctrl+Alt+T",
|
||||||
|
when: { mac: false, commandCenter: false, terminal: false },
|
||||||
|
help: {
|
||||||
|
id: "cycle-theme",
|
||||||
|
section: "panels",
|
||||||
|
label: "Cycle theme",
|
||||||
|
keys: ["mod", "alt", "T"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// --- Message input ---
|
// --- Message input ---
|
||||||
{
|
{
|
||||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { Archive, GitCommitHorizontal, GitMerge, RefreshCcw, Upload } from "lucide-react-native";
|
import {
|
||||||
|
Archive,
|
||||||
|
Download,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
GitMerge,
|
||||||
|
RefreshCcw,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
import { useGitActions } from "@/hooks/use-git-actions";
|
import { useGitActions } from "@/hooks/use-git-actions";
|
||||||
@@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps)
|
|||||||
const icons = useMemo(
|
const icons = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
|
||||||
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||||
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||||
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type CheckoutGitActionStatus = "idle" | "pending" | "success";
|
|||||||
|
|
||||||
export type CheckoutGitAsyncActionId =
|
export type CheckoutGitAsyncActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
|
| "pull"
|
||||||
| "push"
|
| "push"
|
||||||
| "create-pr"
|
| "create-pr"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
@@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState {
|
|||||||
}) => CheckoutGitActionStatus;
|
}) => CheckoutGitActionStatus;
|
||||||
|
|
||||||
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
|
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
|
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
@@ -223,6 +225,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pull: async ({ serverId, cwd }) => {
|
||||||
|
await runCheckoutAction({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
actionId: "pull",
|
||||||
|
run: async () => {
|
||||||
|
const client = resolveClient(serverId);
|
||||||
|
const payload = await client.checkoutPull(cwd);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
push: async ({ serverId, cwd }) => {
|
push: async ({ serverId, cwd }) => {
|
||||||
await runCheckoutAction({
|
await runCheckoutAction({
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||||
|
isWorkspaceActionableForDesktopBadge,
|
||||||
|
} from "./desktop-badge-state";
|
||||||
|
|
||||||
|
describe("desktop-badge-state", () => {
|
||||||
|
it("treats attention-requiring workspace statuses as actionable", () => {
|
||||||
|
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
|
||||||
|
expect(isWorkspaceActionableForDesktopBadge("needs_input")).toBe(true);
|
||||||
|
expect(isWorkspaceActionableForDesktopBadge("failed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores running and done workspace statuses", () => {
|
||||||
|
expect(isWorkspaceActionableForDesktopBadge("running")).toBe(false);
|
||||||
|
expect(isWorkspaceActionableForDesktopBadge("done")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no visible workspaces need attention", () => {
|
||||||
|
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only actionable visible workspaces", () => {
|
||||||
|
expect(
|
||||||
|
deriveMacDockBadgeCountFromWorkspaceStatuses([
|
||||||
|
"done",
|
||||||
|
"attention",
|
||||||
|
"running",
|
||||||
|
"needs_input",
|
||||||
|
"failed",
|
||||||
|
]),
|
||||||
|
).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
|
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
|
||||||
|
|
||||||
|
export function isWorkspaceActionableForDesktopBadge(
|
||||||
|
status: DesktopBadgeWorkspaceStatus,
|
||||||
|
): boolean {
|
||||||
|
return status === "attention" || status === "needs_input" || status === "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
|
||||||
|
statuses: readonly DesktopBadgeWorkspaceStatus[],
|
||||||
|
): number | undefined {
|
||||||
|
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
|
||||||
|
return actionableCount > 0 ? actionableCount : undefined;
|
||||||
|
}
|
||||||
@@ -911,6 +911,61 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requests checkout pull via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.checkoutPull("/tmp/project", "req-pull");
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "checkout_pull_request";
|
||||||
|
cwd: string;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("checkout_pull_request");
|
||||||
|
expect(request.message.cwd).toBe("/tmp/project");
|
||||||
|
expect(request.message.requestId).toBe("req-pull");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout_pull_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
requestId: "req-pull",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
requestId: "req-pull",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("resubscribes checkout diff streams after reconnect", async () => {
|
test("resubscribes checkout diff streams after reconnect", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
CheckoutCommitResponse,
|
CheckoutCommitResponse,
|
||||||
CheckoutMergeResponse,
|
CheckoutMergeResponse,
|
||||||
CheckoutMergeFromBaseResponse,
|
CheckoutMergeFromBaseResponse,
|
||||||
|
CheckoutPullResponse,
|
||||||
CheckoutPushResponse,
|
CheckoutPushResponse,
|
||||||
CheckoutPrCreateResponse,
|
CheckoutPrCreateResponse,
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
@@ -225,6 +226,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
|
|||||||
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||||
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
||||||
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||||
|
type CheckoutPullPayload = CheckoutPullResponse["payload"];
|
||||||
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
type CheckoutPushPayload = CheckoutPushResponse["payload"];
|
||||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
@@ -2385,6 +2387,18 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkoutPull(cwd: string, requestId?: string): Promise<CheckoutPullPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "checkout_pull_request",
|
||||||
|
cwd,
|
||||||
|
},
|
||||||
|
responseType: "checkout_pull_response",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
|
async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ import {
|
|||||||
commitChanges,
|
commitChanges,
|
||||||
mergeToBase,
|
mergeToBase,
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
|
pullCurrentBranch,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
@@ -1685,6 +1686,10 @@ export class Session {
|
|||||||
await this.handleCheckoutMergeFromBaseRequest(msg);
|
await this.handleCheckoutMergeFromBaseRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "checkout_pull_request":
|
||||||
|
await this.handleCheckoutPullRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "checkout_push_request":
|
case "checkout_push_request":
|
||||||
await this.handleCheckoutPushRequest(msg);
|
await this.handleCheckoutPushRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -4706,6 +4711,37 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCheckoutPullRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "checkout_pull_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, requestId } = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pullCurrentBranch(cwd);
|
||||||
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "checkout_pull_response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "checkout_pull_response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: false,
|
||||||
|
error: toCheckoutError(error),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCheckoutPushRequest(
|
private async handleCheckoutPushRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1038,6 +1038,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutPullRequestSchema = z.object({
|
||||||
|
type: z.literal("checkout_pull_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutPushRequestSchema = z.object({
|
export const CheckoutPushRequestSchema = z.object({
|
||||||
type: z.literal("checkout_push_request"),
|
type: z.literal("checkout_push_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1464,6 +1470,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutCommitRequestSchema,
|
CheckoutCommitRequestSchema,
|
||||||
CheckoutMergeRequestSchema,
|
CheckoutMergeRequestSchema,
|
||||||
CheckoutMergeFromBaseRequestSchema,
|
CheckoutMergeFromBaseRequestSchema,
|
||||||
|
CheckoutPullRequestSchema,
|
||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
@@ -2326,6 +2333,16 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutPullResponseSchema = z.object({
|
||||||
|
type: z.literal("checkout_pull_response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutPushResponseSchema = z.object({
|
export const CheckoutPushResponseSchema = z.object({
|
||||||
type: z.literal("checkout_push_response"),
|
type: z.literal("checkout_push_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2825,6 +2842,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutCommitResponseSchema,
|
CheckoutCommitResponseSchema,
|
||||||
CheckoutMergeResponseSchema,
|
CheckoutMergeResponseSchema,
|
||||||
CheckoutMergeFromBaseResponseSchema,
|
CheckoutMergeFromBaseResponseSchema,
|
||||||
|
CheckoutPullResponseSchema,
|
||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
@@ -3054,6 +3072,8 @@ export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
|||||||
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
|
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
|
||||||
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
|
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
|
||||||
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>;
|
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>;
|
||||||
|
export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
|
||||||
|
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
|
||||||
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
|
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
|
||||||
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
|
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
|
||||||
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
|
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
MergeFromBaseConflictError,
|
MergeFromBaseConflictError,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
|
pullCurrentBranch,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
resolveRepositoryDefaultBranch,
|
resolveRepositoryDefaultBranch,
|
||||||
parseWorktreeList,
|
parseWorktreeList,
|
||||||
@@ -558,6 +560,85 @@ const x = 1;
|
|||||||
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
|
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pulls the current branch from origin", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
|
||||||
|
const otherClone = join(tempDir, "other-clone");
|
||||||
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
||||||
|
writeFileSync(join(otherClone, "pulled.txt"), "remote\n");
|
||||||
|
execSync("git add pulled.txt", { cwd: otherClone });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone });
|
||||||
|
const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
|
||||||
|
execSync("git push", { cwd: otherClone });
|
||||||
|
|
||||||
|
await pullCurrentBranch(repoDir);
|
||||||
|
|
||||||
|
execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir });
|
||||||
|
expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts pull on merge conflicts and leaves no merge in progress", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
|
||||||
|
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
|
||||||
|
execSync("git add conflict.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { cwd: repoDir });
|
||||||
|
|
||||||
|
const otherClone = join(tempDir, "other-clone");
|
||||||
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
||||||
|
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
|
||||||
|
execSync("git add conflict.txt", { cwd: otherClone });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone });
|
||||||
|
execSync("git push", { cwd: otherClone });
|
||||||
|
|
||||||
|
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
|
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
|
||||||
|
expect(porcelain).toBe("");
|
||||||
|
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
execSync("git config pull.rebase true", { cwd: repoDir });
|
||||||
|
|
||||||
|
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
|
||||||
|
execSync("git add conflict.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { cwd: repoDir });
|
||||||
|
|
||||||
|
const otherClone = join(tempDir, "other-clone");
|
||||||
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
||||||
|
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
|
||||||
|
execSync("git add conflict.txt", { cwd: otherClone });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", {
|
||||||
|
cwd: otherClone,
|
||||||
|
});
|
||||||
|
execSync("git push", { cwd: otherClone });
|
||||||
|
|
||||||
|
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
|
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim();
|
||||||
|
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
|
||||||
|
expect(porcelain).toBe("");
|
||||||
|
expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false);
|
||||||
|
expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("pushes the current branch to origin", async () => {
|
it("pushes the current branch to origin", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
execSync(`git init --bare -b main ${remoteDir}`);
|
execSync(`git init --bare -b main ${remoteDir}`);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { exec, execFile } from "child_process";
|
|||||||
import { spawnProcess } from "./spawn.js";
|
import { spawnProcess } from "./spawn.js";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { resolve, dirname, basename } from "path";
|
import { resolve, dirname, basename } from "path";
|
||||||
import { realpathSync } from "fs";
|
import { existsSync, realpathSync } from "fs";
|
||||||
import { open as openFile, stat as statFile } from "fs/promises";
|
import { open as openFile, stat as statFile } from "fs/promises";
|
||||||
import { TTLCache } from "@isaacs/ttlcache";
|
import { TTLCache } from "@isaacs/ttlcache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -862,6 +862,46 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
|
|||||||
return url !== null;
|
return url !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
const gitDir = stdout.trim();
|
||||||
|
return gitDir.length > 0 ? gitDir : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||||
|
const gitDir = await resolveAbsoluteGitDir(cwd);
|
||||||
|
if (!gitDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeHeadPath = resolve(gitDir, "MERGE_HEAD");
|
||||||
|
const rebaseMergePath = resolve(gitDir, "rebase-merge");
|
||||||
|
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
|
||||||
|
|
||||||
|
if (existsSync(mergeHeadPath)) {
|
||||||
|
try {
|
||||||
|
await execAsync("git merge --abort", { cwd });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||||
|
try {
|
||||||
|
await execAsync("git rebase --abort", { cwd });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||||
@@ -1555,10 +1595,12 @@ export async function getCheckoutDiff(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `git diff -w --name-status` can still surface paths whose only changes were
|
// `git diff -w --name-status` can still report a modified path even when the
|
||||||
// whitespace. Skip those empty tracked entries so callers don't see phantom files.
|
// whitespace-filtered patch and numstat are both empty. Skip emitting a
|
||||||
|
// structured placeholder in that case so whitespace-only edits truly disappear.
|
||||||
if (
|
if (
|
||||||
ignoreWhitespace &&
|
ignoreWhitespace &&
|
||||||
|
!trackedDiffTruncated &&
|
||||||
change.status.startsWith("M") &&
|
change.status.startsWith("M") &&
|
||||||
(!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0))
|
(!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0))
|
||||||
) {
|
) {
|
||||||
@@ -1859,6 +1901,24 @@ export async function mergeFromBase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pullCurrentBranch(cwd: string): Promise<void> {
|
||||||
|
await requireGitRepo(cwd);
|
||||||
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
|
if (!currentBranch || currentBranch === "HEAD") {
|
||||||
|
throw new Error("Unable to determine current branch for pull");
|
||||||
|
}
|
||||||
|
const hasRemote = await hasOriginRemote(cwd);
|
||||||
|
if (!hasRemote) {
|
||||||
|
throw new Error("Remote 'origin' is not configured.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execAsync("git pull", { cwd });
|
||||||
|
} catch (error) {
|
||||||
|
await abortGitPullConflictState(cwd);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function pushCurrentBranch(cwd: string): Promise<void> {
|
export async function pushCurrentBranch(cwd: string): Promise<void> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
|
|||||||
Reference in New Issue
Block a user