chore(frontend): Add V1 git service API with unified hooks for git changes and diffs (#11565)

This commit is contained in:
sp.wack 2025-10-30 17:03:25 +04:00 committed by GitHub
parent 6558b4f97d
commit 2fc31e96d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 327 additions and 5 deletions

View File

@ -0,0 +1,89 @@
import axios from "axios";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { buildSessionHeaders } from "#/utils/utils";
import { mapV1ToV0Status } from "#/utils/git-status-mapper";
import type {
GitChange,
GitChangeDiff,
V1GitChangeStatus,
} from "../open-hands.types";
interface V1GitChange {
status: V1GitChangeStatus;
path: string;
}
class V1GitService {
/**
* Build the full URL for V1 runtime-specific endpoints
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param path The API path (e.g., "/api/git/changes")
* @returns Full URL to the runtime endpoint
*/
private static buildRuntimeUrl(
conversationUrl: string | null | undefined,
path: string,
): string {
const baseUrl = buildHttpBaseUrl(conversationUrl);
return `${baseUrl}${path}`;
}
/**
* Get git changes for a V1 conversation
* Uses the agent server endpoint: GET /api/git/changes/{path}
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param path The git repository path (e.g., /workspace/project or /workspace/project/OpenHands)
* @returns List of git changes with V0-compatible status types
*/
static async getGitChanges(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/changes/${encodedPath}`,
);
const headers = buildSessionHeaders(sessionApiKey);
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
const { data } = await axios.get<V1GitChange[]>(url, { headers });
// Map V1 statuses to V0 format for compatibility
return data.map((change) => ({
status: mapV1ToV0Status(change.status),
path: change.path,
}));
}
/**
* Get git change diff for a specific file in a V1 conversation
* Uses the agent server endpoint: GET /api/git/diff/{path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param path The file path to get diff for
* @returns Git change diff
*/
static async getGitChangeDiff(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/diff/${encodedPath}`,
);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<GitChangeDiff>(url, { headers });
return data;
}
}
export default V1GitService;

View File

@ -84,8 +84,13 @@ export interface ResultSet<T> {
next_page_id: string | null;
}
/**
* @deprecated Use V1GitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
*/
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
export type V1GitChangeStatus = "MOVED" | "ADDED" | "DELETED" | "UPDATED";
export interface GitChange {
status: GitChangeStatus;
path: string;

View File

@ -7,7 +7,7 @@ import { GitChangeStatus } from "#/api/open-hands.types";
import { getLanguageFromPath } from "#/utils/get-language-from-path";
import { cn } from "#/utils/utils";
import ChevronUp from "#/icons/chveron-up.svg?react";
import { useGitDiff } from "#/hooks/query/use-get-diff";
import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff";
interface LoadingSpinnerProps {
className?: string;
@ -64,7 +64,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
isLoading,
isSuccess,
isRefetching,
} = useGitDiff({
} = useUnifiedGitDiff({
filePath,
type,
enabled: !isCollapsed,

View File

@ -0,0 +1,107 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import V1GitService from "#/api/git-service/v1-git-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { getGitPath } from "#/utils/get-git-path";
import type { GitChange } from "#/api/open-hands.types";
/**
* Unified hook to get git changes for both legacy (V0) and V1 conversations
* - V0: Uses the legacy GitService.getGitChanges API endpoint
* - V1: Uses the V1GitService.getGitChanges API endpoint with runtime URL
*/
export const useUnifiedGetGitChanges = () => {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
const previousDataRef = React.useRef<GitChange[] | null>(null);
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
const conversationUrl = conversation?.url;
const sessionApiKey = conversation?.session_api_key;
const selectedRepository = conversation?.selected_repository;
// Calculate git path based on selected repository
const gitPath = React.useMemo(
() => getGitPath(selectedRepository),
[selectedRepository],
);
const result = useQuery({
queryKey: [
"file_changes",
conversationId,
isV1Conversation,
conversationUrl,
gitPath,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Use the V1 API endpoint with runtime URL
if (isV1Conversation) {
return V1GitService.getGitChanges(
conversationUrl,
sessionApiKey,
gitPath,
);
}
// V0 (Legacy): Use the legacy API endpoint
return GitService.getGitChanges(conversationId);
},
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
disableToast: true,
},
});
// Latest changes should be on top
React.useEffect(() => {
if (!result.isFetching && result.isSuccess && result.data) {
const currentData = result.data;
// If this is new data (not the same reference as before)
if (currentData !== previousDataRef.current) {
previousDataRef.current = currentData;
// Figure out new items by comparing with what we already have
if (Array.isArray(currentData)) {
const currentIds = new Set(currentData.map((item) => item.path));
const existingIds = new Set(orderedChanges.map((item) => item.path));
// Filter out items that already exist in orderedChanges
const newItems = currentData.filter(
(item) => !existingIds.has(item.path),
);
// Filter out items that no longer exist in the API response
const existingItems = orderedChanges.filter((item) =>
currentIds.has(item.path),
);
// Add new items to the beginning
setOrderedChanges([...newItems, ...existingItems]);
} else {
// If not an array, just use the data directly
setOrderedChanges([currentData]);
}
}
}
}, [result.isFetching, result.isSuccess, result.data]);
return {
data: orderedChanges,
isLoading: result.isLoading,
isSuccess: result.isSuccess,
isError: result.isError,
error: result.error,
};
};

View File

@ -0,0 +1,67 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import V1GitService from "#/api/git-service/v1-git-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { getGitPath } from "#/utils/get-git-path";
import type { GitChangeStatus } from "#/api/open-hands.types";
type UseUnifiedGitDiffConfig = {
filePath: string;
type: GitChangeStatus;
enabled: boolean;
};
/**
* Unified hook to get git diff for both legacy (V0) and V1 conversations
* - V0: Uses the legacy GitService.getGitChangeDiff API endpoint
* - V1: Uses the V1GitService.getGitChangeDiff API endpoint with runtime URL
*/
export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
const conversationUrl = conversation?.url;
const sessionApiKey = conversation?.session_api_key;
const selectedRepository = conversation?.selected_repository;
// For V1, we need to convert the relative file path to an absolute path
// The diff endpoint expects: /workspace/project/RepoName/relative/path
const absoluteFilePath = React.useMemo(() => {
if (!isV1Conversation) return config.filePath;
const gitPath = getGitPath(selectedRepository);
return `${gitPath}/${config.filePath}`;
}, [isV1Conversation, selectedRepository, config.filePath]);
return useQuery({
queryKey: [
"file_diff",
conversationId,
config.filePath,
config.type,
isV1Conversation,
conversationUrl,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Use the V1 API endpoint with runtime URL and absolute path
if (isV1Conversation) {
return V1GitService.getGitChangeDiff(
conversationUrl,
sessionApiKey,
absoluteFilePath,
);
}
// V0 (Legacy): Use the legacy API endpoint with relative path
return GitService.getGitChangeDiff(conversationId, config.filePath);
},
enabled: config.enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import React from "react";
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
@ -27,7 +27,7 @@ function GitChanges() {
isError,
error,
isLoading: loadingGitChanges,
} = useGetGitChanges();
} = useUnifiedGetGitChanges();
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
null,

View File

@ -24,6 +24,7 @@ export const handleActionEventCacheInvalidation = (
// Invalidate file_changes cache for file-related actions
if (
action.kind === "StrReplaceEditorAction" ||
action.kind === "FileEditorAction" ||
action.kind === "ExecuteBashAction"
) {
queryClient.invalidateQueries(
@ -35,7 +36,11 @@ export const handleActionEventCacheInvalidation = (
}
// Invalidate specific file diff cache for file modifications
if (action.kind === "StrReplaceEditorAction" && action.path) {
if (
(action.kind === "StrReplaceEditorAction" ||
action.kind === "FileEditorAction") &&
action.path
) {
const strippedPath = stripWorkspacePrefix(action.path);
queryClient.invalidateQueries({
queryKey: ["file_diff", conversationId, strippedPath],

View File

@ -0,0 +1,22 @@
/**
* Get the git repository path for a conversation
* If a repository is selected, returns /workspace/project/{repo-name}
* Otherwise, returns /workspace/project
*
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo")
* @returns The git path to use
*/
export function getGitPath(
selectedRepository: string | null | undefined,
): string {
if (!selectedRepository) {
return "/workspace/project";
}
// Extract the repository name from "owner/repo" format
// The folder name is the second part after "/"
const parts = selectedRepository.split("/");
const repoName = parts.length > 1 ? parts[1] : parts[0];
return `/workspace/project/${repoName}`;
}

View File

@ -0,0 +1,27 @@
import type {
GitChangeStatus,
V1GitChangeStatus,
} from "#/api/open-hands.types";
/**
* Maps V1 git change status to legacy V0 status format
*
* V1 -> V0 mapping:
* - ADDED -> A (Added)
* - DELETED -> D (Deleted)
* - UPDATED -> M (Modified)
* - MOVED -> R (Renamed)
*
* @param v1Status The V1 git change status
* @returns The equivalent V0 git change status
*/
export function mapV1ToV0Status(v1Status: V1GitChangeStatus): GitChangeStatus {
const statusMap: Record<V1GitChangeStatus, GitChangeStatus> = {
ADDED: "A",
DELETED: "D",
UPDATED: "M",
MOVED: "R",
};
return statusMap[v1Status];
}