feat(sidebar): add PR status hint badge to workspace rows
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
|||||||
CircleHelp,
|
CircleHelp,
|
||||||
Copy,
|
Copy,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
|
GitPullRequest,
|
||||||
Monitor,
|
Monitor,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -78,11 +79,13 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
|
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -95,6 +98,12 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
|||||||
|
|
||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
const EMPTY_WORKSPACES = new Map();
|
const EMPTY_WORKSPACES = new Map();
|
||||||
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
|
const GITHUB_PR_STATE_COLORS: Record<PrHint["state"], string> = {
|
||||||
|
open: "#3fb950",
|
||||||
|
merged: "#a371f7",
|
||||||
|
closed: "#f85149",
|
||||||
|
};
|
||||||
|
|
||||||
interface SidebarWorkspaceListProps {
|
interface SidebarWorkspaceListProps {
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
@@ -153,6 +162,45 @@ interface WorkspaceRowInnerProps {
|
|||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||||
|
const color = GITHUB_PR_STATE_COLORS[hint.state];
|
||||||
|
|
||||||
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePress = useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openExternalUrl(hint.url);
|
||||||
|
},
|
||||||
|
[hint.url],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="link"
|
||||||
|
accessibilityLabel={`${hint.state} pull request #${hint.number}`}
|
||||||
|
hitSlop={4}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.workspacePrBadge,
|
||||||
|
{
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}1a`,
|
||||||
|
},
|
||||||
|
pressed && styles.workspacePrBadgePressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<GitPullRequest size={12} color={color} />
|
||||||
|
<Text style={[styles.workspacePrBadgeText, { color }]} numberOfLines={1}>
|
||||||
|
#{hint.number}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStatusDotColor(input: {
|
function resolveStatusDotColor(input: {
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||||
bucket: SidebarStateBucket;
|
bucket: SidebarStateBucket;
|
||||||
@@ -743,6 +791,11 @@ function WorkspaceRowInner({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = Platform.OS !== "web";
|
const isMobile = Platform.OS !== "web";
|
||||||
|
const prHint = useWorkspacePrHint({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
cwd: workspace.workspaceId,
|
||||||
|
enabled: workspace.workspaceKind !== "directory",
|
||||||
|
});
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController,
|
||||||
@@ -777,92 +830,99 @@ function WorkspaceRowInner({
|
|||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
<View
|
<View style={styles.workspaceRowMain}>
|
||||||
{...(dragHandleProps?.attributes as any)}
|
<View
|
||||||
{...(dragHandleProps?.listeners as any)}
|
{...(dragHandleProps?.attributes as any)}
|
||||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
{...(dragHandleProps?.listeners as any)}
|
||||||
style={styles.workspaceRowLeft}
|
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||||
>
|
style={styles.workspaceRowLeft}
|
||||||
<WorkspaceStatusIndicator
|
|
||||||
bucket={workspace.statusBucket}
|
|
||||||
workspaceKind={workspace.workspaceKind}
|
|
||||||
loading={isArchiving || isCreating}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.workspaceBranchText,
|
|
||||||
isHovered && styles.workspaceBranchTextHovered,
|
|
||||||
isCreating && styles.workspaceBranchTextCreating,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
>
|
||||||
{workspace.name}
|
<WorkspaceStatusIndicator
|
||||||
</Text>
|
bucket={workspace.statusBucket}
|
||||||
</View>
|
workspaceKind={workspace.workspaceKind}
|
||||||
<View style={styles.workspaceRowRight}>
|
loading={isArchiving || isCreating}
|
||||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
/>
|
||||||
{onArchive && (isHovered || isMobile) ? (
|
<Text
|
||||||
<DropdownMenu>
|
style={[
|
||||||
<DropdownMenuTrigger
|
styles.workspaceBranchText,
|
||||||
hitSlop={8}
|
isHovered && styles.workspaceBranchTextHovered,
|
||||||
style={({ hovered = false }) => [
|
isCreating && styles.workspaceBranchTextCreating,
|
||||||
styles.kebabButton,
|
]}
|
||||||
hovered && styles.kebabButtonHovered,
|
numberOfLines={1}
|
||||||
]}
|
>
|
||||||
accessibilityRole="button"
|
{workspace.name}
|
||||||
accessibilityLabel="Workspace actions"
|
</Text>
|
||||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
</View>
|
||||||
>
|
<View style={styles.workspaceRowRight}>
|
||||||
{({ hovered }) => (
|
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||||
<MoreVertical
|
{onArchive && (isHovered || isMobile) ? (
|
||||||
size={14}
|
<DropdownMenu>
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
<DropdownMenuTrigger
|
||||||
/>
|
hitSlop={8}
|
||||||
)}
|
style={({ hovered = false }) => [
|
||||||
</DropdownMenuTrigger>
|
styles.kebabButton,
|
||||||
<DropdownMenuContent align="end" width={260}>
|
hovered && styles.kebabButtonHovered,
|
||||||
{onCopyPath ? (
|
]}
|
||||||
<DropdownMenuItem
|
accessibilityRole="button"
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
accessibilityLabel="Workspace actions"
|
||||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||||
onSelect={onCopyPath}
|
|
||||||
>
|
|
||||||
Copy path
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onCopyBranchName ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
|
||||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
|
||||||
onSelect={onCopyBranchName}
|
|
||||||
>
|
|
||||||
Copy branch name
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
|
||||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
|
||||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
|
||||||
status={archiveStatus}
|
|
||||||
pendingLabel={archivePendingLabel}
|
|
||||||
onSelect={onArchive}
|
|
||||||
>
|
>
|
||||||
{archiveLabel ?? "Archive"}
|
{({ hovered }) => (
|
||||||
</DropdownMenuItem>
|
<MoreVertical
|
||||||
</DropdownMenuContent>
|
size={14}
|
||||||
</DropdownMenu>
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
) : workspace.diffStat ? (
|
/>
|
||||||
<View style={styles.diffStatRow}>
|
)}
|
||||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
</DropdownMenuTrigger>
|
||||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
<DropdownMenuContent align="end" width={260}>
|
||||||
</View>
|
{onCopyPath ? (
|
||||||
) : null}
|
<DropdownMenuItem
|
||||||
{showShortcutBadge && shortcutNumber !== null ? (
|
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||||
<View style={styles.shortcutBadge}>
|
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
onSelect={onCopyPath}
|
||||||
</View>
|
>
|
||||||
) : null}
|
Copy path
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onCopyBranchName ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||||
|
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
onSelect={onCopyBranchName}
|
||||||
|
>
|
||||||
|
Copy branch name
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||||
|
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||||
|
status={archiveStatus}
|
||||||
|
pendingLabel={archivePendingLabel}
|
||||||
|
onSelect={onArchive}
|
||||||
|
>
|
||||||
|
{archiveLabel ?? "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : workspace.diffStat ? (
|
||||||
|
<View style={styles.diffStatRow}>
|
||||||
|
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||||
|
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
|
<View style={styles.shortcutBadge}>
|
||||||
|
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{prHint ? (
|
||||||
|
<View style={styles.workspacePrBadgeRow}>
|
||||||
|
<WorkspacePrBadge hint={prHint} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -2001,10 +2061,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
workspaceRowMain: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
width: "100%",
|
||||||
},
|
},
|
||||||
workspaceRowLeft: {
|
workspaceRowLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -2044,7 +2111,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
width: 14,
|
width: WORKSPACE_STATUS_DOT_WIDTH,
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -2085,6 +2152,27 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceBranchTextHovered: {
|
workspaceBranchTextHovered: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
workspacePrBadgeRow: {
|
||||||
|
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
|
||||||
|
},
|
||||||
|
workspacePrBadge: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
workspacePrBadgePressed: {
|
||||||
|
opacity: 0.82,
|
||||||
|
},
|
||||||
|
workspacePrBadgeText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
lineHeight: 14,
|
||||||
|
},
|
||||||
workspaceCreatingText: {
|
workspaceCreatingText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||||
|
|
||||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
||||||
|
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
|
||||||
|
|
||||||
function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||||
@@ -15,6 +16,48 @@ interface UseCheckoutPrStatusQueryOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
|
export interface PrHint {
|
||||||
|
url: string;
|
||||||
|
number: number;
|
||||||
|
state: "open" | "merged" | "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePullRequestNumber(url: string): number | null {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = Number.parseInt(match[1], 10);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
|
||||||
|
const status = payload.status;
|
||||||
|
if (!status?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = parsePullRequestNumber(status.url);
|
||||||
|
if (number === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: status.url,
|
||||||
|
number,
|
||||||
|
state: status.isMerged || status.state === "MERGED"
|
||||||
|
? "merged"
|
||||||
|
: status.state === "OPEN"
|
||||||
|
? "open"
|
||||||
|
: "closed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useCheckoutPrStatusQuery({
|
export function useCheckoutPrStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -48,3 +91,28 @@ export function useCheckoutPrStatusQuery({
|
|||||||
refresh: query.refetch,
|
refresh: query.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWorkspacePrHint({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
enabled = true,
|
||||||
|
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||||
|
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client not available");
|
||||||
|
}
|
||||||
|
return await client.checkoutPrStatus(cwd);
|
||||||
|
},
|
||||||
|
enabled: !!client && isConnected && !!cwd && enabled,
|
||||||
|
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||||
|
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
|
||||||
|
select: selectWorkspacePrHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.data ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user