mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
chore(frontend): Add V1 git service API with unified hooks for git changes and diffs (#11565)
This commit is contained in:
parent
6558b4f97d
commit
2fc31e96d0
89
frontend/src/api/git-service/v1-git-service.api.ts
Normal file
89
frontend/src/api/git-service/v1-git-service.api.ts
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
107
frontend/src/hooks/query/use-unified-get-git-changes.ts
Normal file
107
frontend/src/hooks/query/use-unified-get-git-changes.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
67
frontend/src/hooks/query/use-unified-git-diff.ts
Normal file
67
frontend/src/hooks/query/use-unified-git-diff.ts
Normal 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
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
22
frontend/src/utils/get-git-path.ts
Normal file
22
frontend/src/utils/get-git-path.ts
Normal 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}`;
|
||||
}
|
||||
27
frontend/src/utils/git-status-mapper.ts
Normal file
27
frontend/src/utils/git-status-mapper.ts
Normal 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];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user