mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: Diff UI (#6934)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "#/message";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import type { Message } from "#/message";
|
||||
import { addUserMessage } from "#/state/chat-slice";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import * as ChatSlice from "#/state/chat-slice";
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Conversation,
|
||||
ResultSet,
|
||||
GetTrajectoryResponse,
|
||||
GitChangeDiff,
|
||||
GitChange,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
@@ -277,6 +279,26 @@ class OpenHands {
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-settings-tokens";
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
|
||||
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
|
||||
const { data } = await openHands.get<GitChange[]>(
|
||||
`/api/conversations/${conversationId}/git/changes`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitChangeDiff(
|
||||
conversationId: string,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const { data } = await openHands.get<GitChangeDiff>(
|
||||
`/api/conversations/${conversationId}/git/diff`,
|
||||
{
|
||||
params: { path },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -83,3 +83,15 @@ export interface ResultSet<T> {
|
||||
results: T[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
|
||||
|
||||
export interface GitChange {
|
||||
status: GitChangeStatus;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitChangeDiff {
|
||||
modified: string;
|
||||
original: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import React from "react";
|
||||
import { editor as editor_t } from "monaco-editor";
|
||||
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
|
||||
import { IconType } from "react-icons/lib";
|
||||
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";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO: Move out of this file and replace the current spinner with this one
|
||||
function LoadingSpinner({ className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"animate-spin rounded-full border-4 border-gray-200 border-t-blue-500",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<GitChangeStatus, string | IconType> = {
|
||||
A: LuFilePlus,
|
||||
D: LuFileMinus,
|
||||
M: LuFileDiff,
|
||||
R: "Renamed",
|
||||
U: "Untracked",
|
||||
};
|
||||
|
||||
export interface FileDiffViewerProps {
|
||||
path: string;
|
||||
type: GitChangeStatus;
|
||||
}
|
||||
|
||||
export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(true);
|
||||
const [editorHeight, setEditorHeight] = React.useState(400);
|
||||
const diffEditorRef = React.useRef<editor_t.IStandaloneDiffEditor>(null);
|
||||
|
||||
const isAdded = type === "A" || type === "U";
|
||||
const isDeleted = type === "D";
|
||||
|
||||
const filePath = React.useMemo(() => {
|
||||
if (type === "R") {
|
||||
const parts = path.split(/\s+/).slice(1);
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
return path;
|
||||
}, [path, type]);
|
||||
|
||||
const {
|
||||
data: diff,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isRefetching,
|
||||
} = useGitDiff({
|
||||
filePath,
|
||||
type,
|
||||
enabled: !isCollapsed,
|
||||
});
|
||||
|
||||
// Function to update editor height based on content
|
||||
const updateEditorHeight = React.useCallback(() => {
|
||||
if (diffEditorRef.current) {
|
||||
const originalEditor = diffEditorRef.current.getOriginalEditor();
|
||||
const modifiedEditor = diffEditorRef.current.getModifiedEditor();
|
||||
|
||||
if (originalEditor && modifiedEditor) {
|
||||
// Get the content height from both editors and use the larger one
|
||||
const originalHeight = originalEditor.getContentHeight();
|
||||
const modifiedHeight = modifiedEditor.getContentHeight();
|
||||
const contentHeight = Math.max(originalHeight, modifiedHeight);
|
||||
|
||||
// Add a small buffer to avoid scrollbar
|
||||
setEditorHeight(contentHeight + 20);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
|
||||
diffEditorRef.current = editor;
|
||||
updateEditorHeight();
|
||||
|
||||
const originalEditor = editor.getOriginalEditor();
|
||||
const modifiedEditor = editor.getModifiedEditor();
|
||||
|
||||
originalEditor.onDidContentSizeChange(updateEditorHeight);
|
||||
modifiedEditor.onDidContentSizeChange(updateEditorHeight);
|
||||
};
|
||||
|
||||
const status = type === "U" ? STATUS_MAP.A : STATUS_MAP[type];
|
||||
|
||||
let statusIcon: React.ReactNode;
|
||||
if (typeof status === "string") {
|
||||
statusIcon = <span>{status}</span>;
|
||||
} else {
|
||||
const StatusIcon = status; // now it's recognized as a component
|
||||
statusIcon = <StatusIcon className="w-5 h-5" />;
|
||||
}
|
||||
|
||||
const isFetchingData = isLoading || isRefetching;
|
||||
|
||||
return (
|
||||
<div data-testid="file-diff-viewer-outer" className="w-full flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between items-center px-2.5 py-3.5 border border-neutral-600 rounded-xl hover:cursor-pointer",
|
||||
!isCollapsed && !isLoading && "border-b-0 rounded-b-none",
|
||||
)}
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm w-full text-content flex items-center gap-2">
|
||||
{isFetchingData && <LoadingSpinner className="w-5 h-5" />}
|
||||
{!isFetchingData && statusIcon}
|
||||
<strong className="w-full truncate">{filePath}</strong>
|
||||
<button data-testid="collapse" type="button">
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform",
|
||||
isCollapsed && "transform rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{isSuccess && !isCollapsed && (
|
||||
<div
|
||||
className="w-full border border-neutral-600 overflow-hidden"
|
||||
style={{ height: `${editorHeight}px` }}
|
||||
>
|
||||
<DiffEditor
|
||||
data-testid="file-diff-viewer"
|
||||
className="w-full h-full"
|
||||
language={getLanguageFromPath(filePath)}
|
||||
original={isAdded ? "" : diff.original}
|
||||
modified={isDeleted ? "" : diff.modified}
|
||||
theme="vs-dark"
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
renderValidationDecorations: "off",
|
||||
readOnly: true,
|
||||
renderSideBySide: !isAdded && !isDeleted,
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
hideUnchangedRegions: {
|
||||
enabled: true,
|
||||
},
|
||||
automaticLayout: true,
|
||||
scrollbar: {
|
||||
// Make scrollbar less intrusive
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface ContainerProps {
|
||||
to: string;
|
||||
icon?: React.ReactNode;
|
||||
isBeta?: boolean;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
@@ -29,8 +30,15 @@ export function Container({
|
||||
>
|
||||
{labels && (
|
||||
<div className="flex text-xs h-[36px]">
|
||||
{labels.map(({ label: l, to, icon, isBeta }) => (
|
||||
<NavTab key={to} to={to} label={l} icon={icon} isBeta={isBeta} />
|
||||
{labels.map(({ label: l, to, icon, isBeta, isLoading }) => (
|
||||
<NavTab
|
||||
key={to}
|
||||
to={to}
|
||||
label={l}
|
||||
icon={icon}
|
||||
isBeta={isBeta}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NavLink } from "react-router";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BetaBadge } from "./beta-badge";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
|
||||
interface NavTabProps {
|
||||
to: string;
|
||||
label: string | React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
isBeta?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function NavTab({ to, label, icon, isBeta }: NavTabProps) {
|
||||
export function NavTab({ to, label, icon, isBeta, isLoading }: NavTabProps) {
|
||||
return (
|
||||
<NavLink
|
||||
end
|
||||
@@ -23,9 +25,13 @@ export function NavTab({ to, label, icon, isBeta }: NavTabProps) {
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className={cn(isActive && "text-logo")}>{icon}</div>
|
||||
{label}
|
||||
{isBeta && <BetaBadge />}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(isActive && "text-logo")}>{icon}</div>
|
||||
{label}
|
||||
{isBeta && <BetaBadge />}
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { showChatError } from "#/utils/error-handler";
|
||||
@@ -7,8 +8,12 @@ import { useRate } from "#/hooks/use-rate";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
CommandAction,
|
||||
FileEditAction,
|
||||
FileWriteAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
@@ -19,6 +24,17 @@ const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
const isFileWriteAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is FileWriteAction => "action" in event && event.action === "write";
|
||||
|
||||
const isFileEditAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is FileEditAction => "action" in event && event.action === "edit";
|
||||
|
||||
const isCommandAction = (event: OpenHandsParsedEvent): event is CommandAction =>
|
||||
"action" in event && event.action === "run";
|
||||
|
||||
const isUserMessage = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is UserMessageAction =>
|
||||
@@ -105,6 +121,7 @@ export function WsClientProvider({
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const [status, setStatus] = React.useState(
|
||||
WsClientProviderStatus.DISCONNECTED,
|
||||
@@ -128,9 +145,46 @@ export function WsClientProvider({
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
if (isOpenHandsEvent(event) && isMessageAction(event)) {
|
||||
messageRateHandler.record(new Date().getTime());
|
||||
if (isOpenHandsEvent(event)) {
|
||||
if (isMessageAction(event)) {
|
||||
messageRateHandler.record(new Date().getTime());
|
||||
}
|
||||
|
||||
// Invalidate diffs cache when a file is edited or written
|
||||
if (
|
||||
isFileEditAction(event) ||
|
||||
isFileWriteAction(event) ||
|
||||
isCommandAction(event)
|
||||
) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["file_changes", conversationId],
|
||||
});
|
||||
|
||||
// Invalidate file diff cache when a file is edited or written
|
||||
if (!isCommandAction(event)) {
|
||||
const cachedConversaton = queryClient.getQueryData<Conversation>([
|
||||
"user",
|
||||
"conversation",
|
||||
conversationId,
|
||||
]);
|
||||
const clonedRepositoryDirectory =
|
||||
cachedConversaton?.selected_repository?.split("/").pop();
|
||||
|
||||
let fileToInvalidate = event.args.path.replace("/workspace/", "");
|
||||
if (clonedRepositoryDirectory) {
|
||||
fileToInvalidate = fileToInvalidate.replace(
|
||||
`${clonedRepositoryDirectory}/`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["file_diff", conversationId, fileToInvalidate],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEvents((prevEvents) => [...prevEvents, event]);
|
||||
if (!Number.isNaN(parseInt(event.id as string, 10))) {
|
||||
lastEventRef.current = event;
|
||||
|
||||
22
frontend/src/hooks/query/use-get-diff.ts
Normal file
22
frontend/src/hooks/query/use-get-diff.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitChangeStatus } from "#/api/open-hands.types";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type UseGetDiffConfig = {
|
||||
filePath: string;
|
||||
type: GitChangeStatus;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const useGitDiff = (config: UseGetDiffConfig) => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["file_diff", conversationId, config.filePath, config.type],
|
||||
queryFn: () => OpenHands.getGitChangeDiff(conversationId, config.filePath),
|
||||
enabled: config.enabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
63
frontend/src/hooks/query/use-get-git-changes.ts
Normal file
63
frontend/src/hooks/query/use-get-git-changes.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import { GitChange } from "#/api/open-hands.types";
|
||||
|
||||
export const useGetGitChanges = () => {
|
||||
const { conversationId } = useConversation();
|
||||
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
|
||||
const previousDataRef = React.useRef<GitChange[]>(null);
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: ["file_changes", conversationId],
|
||||
queryFn: () => OpenHands.getGitChanges(conversationId),
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Latest changes should be on top
|
||||
React.useEffect(() => {
|
||||
if (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.data]);
|
||||
|
||||
return {
|
||||
data: orderedChanges,
|
||||
isSuccess: result.isSuccess,
|
||||
isError: result.isError,
|
||||
error: result.error,
|
||||
};
|
||||
};
|
||||
@@ -408,4 +408,9 @@ export enum I18nKey {
|
||||
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",
|
||||
DIFF_VIEWER$NOT_A_GIT_REPO = "DIFF_VIEWER$NOT_A_GIT_REPO",
|
||||
DIFF_VIEWER$ASK_OH = "DIFF_VIEWER$ASK_OH",
|
||||
DIFF_VIEWER$NO_CHANGES = "DIFF_VIEWER$NO_CHANGES",
|
||||
}
|
||||
|
||||
@@ -6088,5 +6088,80 @@
|
||||
"fr": "documentation",
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation"
|
||||
},
|
||||
"DIFF_VIEWER$LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
"zh-CN": "加载中...",
|
||||
"zh-TW": "加載中...",
|
||||
"ko-KR": "로딩 중...",
|
||||
"no": "Laster inn...",
|
||||
"it": "Caricamento in corso...",
|
||||
"pt": "Carregando...",
|
||||
"es": "Cargando...",
|
||||
"ar": "جار التحميل...",
|
||||
"fr": "Chargement...",
|
||||
"tr": "Yükleniyor...",
|
||||
"de": "Wird geladen..."
|
||||
},
|
||||
"DIFF_VIEWER$GETTING_LATEST_CHANGES": {
|
||||
"en": "Getting latest changes...",
|
||||
"ja": "最新の変更を取得中...",
|
||||
"zh-CN": "获取最新更改中...",
|
||||
"zh-TW": "獲取最新更改中...",
|
||||
"ko-KR": "최신 변경 사항 가져오는 중...",
|
||||
"no": "Henter de nyeste endringene...",
|
||||
"it": "Ottenendo le ultime modifiche...",
|
||||
"pt": "Obtendo as últimas alterações...",
|
||||
"es": "Obteniendo los últimos cambios...",
|
||||
"ar": "جارٍ الحصول على أحدث التغييرات...",
|
||||
"fr": "Obtention des dernières modifications...",
|
||||
"tr": "Son değişiklikleri alıyor...",
|
||||
"de": "Aktuellste Änderungen abrufen..."
|
||||
},
|
||||
"DIFF_VIEWER$NOT_A_GIT_REPO": {
|
||||
"en": "Your current workspace is not a git repository.",
|
||||
"ja": "現在のワークスペースはgitリポジトリではありません。",
|
||||
"zh-CN": "您当前的工作区不是git存储库。",
|
||||
"zh-TW": "您當前的工作區不是git存儲庫。",
|
||||
"ko-KR": "현재 작업 공간은 git 리포지토리가 아닙니다.",
|
||||
"no": "Nåværende arbeidsområde er ikke et git-repositorium.",
|
||||
"it": "L'area di lavoro corrente non è un repository git.",
|
||||
"pt": "Seu espaço de trabalho atual não é um repositório git.",
|
||||
"es": "Su espacio de trabajo actual no es un repositorio git.",
|
||||
"ar": "مساحة العمل الحالية الخاصة بك ليست مستودع git.",
|
||||
"fr": "Votre espace de travail actuel n'est pas un dépôt git.",
|
||||
"tr": "Mevcut çalışma alanınız bir git deposu değil.",
|
||||
"de": "Ihr aktueller Arbeitsbereich ist kein git-Repository."
|
||||
},
|
||||
"DIFF_VIEWER$ASK_OH": {
|
||||
"en": "Ask OpenHands to initialize a git repo to activate this UI.",
|
||||
"ja": "このUIを有効にするために、OpenHandsにgitリポジトリを初期化するよう依頼します。",
|
||||
"zh-CN": "请OpenHands初始化git存储库以激活此UI。",
|
||||
"zh-TW": "請OpenHands初始化git存儲庫以啟用此UI。",
|
||||
"ko-KR": "이 UI를 활성화하려면 OpenHands에 git 리포지토리를 초기화하도록 요청하세요.",
|
||||
"no": "Be OpenHands om å initialisere et git-repositorium for å aktivere dette brukergrensesnittet.",
|
||||
"it": "Chiedi a OpenHands di inizializzare un repository git per attivare questa interfaccia utente.",
|
||||
"pt": "Peça ao OpenHands para inicializar um repositório git para ativar esta interface.",
|
||||
"es": "Pida a OpenHands que inicialice un repositorio git para activar esta interfaz.",
|
||||
"ar": "اطلب من OpenHands تهيئة مستودع git لتنشيط واجهة المستخدم هذه.",
|
||||
"fr": "Demandez à OpenHands d'initialiser un dépôt git pour activer cette interface utilisateur.",
|
||||
"tr": "Bu UI'yi etkinleştirmek için OpenHands'tan bir git deposunu başlatmasını isteyin.",
|
||||
"de": "Bitten Sie OpenHands, ein git-Repository zu initialisieren, um diese Benutzeroberfläche zu aktivieren."
|
||||
},
|
||||
"DIFF_VIEWER$NO_CHANGES": {
|
||||
"en": "OpenHands hasn't made any changes yet...",
|
||||
"ja": "OpenHandsはまだ変更を加えていません...",
|
||||
"zh-CN": "OpenHands尚未进行任何更改...",
|
||||
"zh-TW": "OpenHands尚未進行任何更改...",
|
||||
"ko-KR": "OpenHands는 아직 변경하지 않았습니다...",
|
||||
"no": "OpenHands har ikke gjort noen endringer ennå...",
|
||||
"it": "OpenHands non ha ancora apportato modifiche...",
|
||||
"pt": "O OpenHands ainda não fez nenhuma alteração...",
|
||||
"es": "OpenHands aún no ha realizado ningún cambio...",
|
||||
"ar": "لم يقم OpenHands بإجراء أي تغييرات بعد ...",
|
||||
"fr": "OpenHands n'a pas encore apporté de modifications ...",
|
||||
"tr": "OpenHands henüz herhangi bir değişiklik yapmadı ...",
|
||||
"de": "OpenHands hat noch keine Änderungen vorgenommen..."
|
||||
}
|
||||
}
|
||||
|
||||
3
frontend/src/icons/chveron-up.svg
Normal file
3
frontend/src/icons/chveron-up.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="13" height="8" viewBox="0 0 13 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.259766 6.17427L6.26425 0.715628L12.2688 6.17427L11.2597 7.28418L6.26425 2.74282L1.26878 7.28418L0.259766 6.17427Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
@@ -13,7 +13,8 @@ export default [
|
||||
route("billing", "routes/billing.tsx"),
|
||||
]),
|
||||
route("conversations/:conversationId", "routes/conversation.tsx", [
|
||||
index("routes/editor-tab.tsx"),
|
||||
index("routes/editor.tsx"),
|
||||
route("workspace", "routes/editor-tab.tsx"),
|
||||
route("browser", "routes/browser-tab.tsx"),
|
||||
route("jupyter", "routes/jupyter-tab.tsx"),
|
||||
route("served", "routes/served-tab.tsx"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiGit } from "react-icons/di";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
ConversationProvider,
|
||||
@@ -128,8 +129,13 @@ function AppContent() {
|
||||
className="h-full w-full"
|
||||
labels={[
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TITLE),
|
||||
label: "Changes",
|
||||
to: "",
|
||||
icon: <DiGit className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TITLE),
|
||||
to: "workspace",
|
||||
icon: <CodeIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { getLanguageFromPath } from "#/utils/get-language-from-path";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -18,56 +19,6 @@ export function ErrorBoundary() {
|
||||
);
|
||||
}
|
||||
|
||||
function getLanguageFromPath(path: string): string {
|
||||
const extension = path.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "js":
|
||||
case "jsx":
|
||||
return "javascript";
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return "typescript";
|
||||
case "py":
|
||||
return "python";
|
||||
case "html":
|
||||
return "html";
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return "json";
|
||||
case "md":
|
||||
return "markdown";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "bash";
|
||||
case "dockerfile":
|
||||
return "dockerfile";
|
||||
case "rs":
|
||||
return "rust";
|
||||
case "go":
|
||||
return "go";
|
||||
case "java":
|
||||
return "java";
|
||||
case "cpp":
|
||||
case "cc":
|
||||
case "cxx":
|
||||
return "cpp";
|
||||
case "c":
|
||||
return "c";
|
||||
case "rb":
|
||||
return "ruby";
|
||||
case "php":
|
||||
return "php";
|
||||
case "sql":
|
||||
return "sql";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
function FileViewer() {
|
||||
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
|
||||
const { selectedPath, files } = useFiles();
|
||||
|
||||
50
frontend/src/routes/editor.tsx
Normal file
50
frontend/src/routes/editor.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function StatusMessage({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: gitChanges, isSuccess, isError, error } = useGetGitChanges();
|
||||
|
||||
const isNotGitRepoError =
|
||||
error && retrieveAxiosErrorMessage(error) === "Not a git repository";
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-scroll px-4 py-3 gap-3 flex flex-col">
|
||||
{!isNotGitRepoError && error && (
|
||||
<StatusMessage>{retrieveAxiosErrorMessage(error)}</StatusMessage>
|
||||
)}
|
||||
{isNotGitRepoError && (
|
||||
<StatusMessage>
|
||||
{t(I18nKey.DIFF_VIEWER$NOT_A_GIT_REPO)}
|
||||
<br />
|
||||
{t(I18nKey.DIFF_VIEWER$ASK_OH)}
|
||||
</StatusMessage>
|
||||
)}
|
||||
|
||||
{!isError && gitChanges?.length === 0 && (
|
||||
<StatusMessage>{t(I18nKey.DIFF_VIEWER$NO_CHANGES)}</StatusMessage>
|
||||
)}
|
||||
{isSuccess &&
|
||||
gitChanges.map((change) => (
|
||||
<FileDiffViewer
|
||||
key={change.path}
|
||||
path={change.path}
|
||||
type={change.status}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorScreen;
|
||||
@@ -118,13 +118,13 @@ export default function MainApp() {
|
||||
return (
|
||||
<div
|
||||
data-testid="root-layout"
|
||||
className="bg-base p-3 h-screen md:min-w-[1024px] overflow-x-hidden flex flex-col md:flex-row gap-3"
|
||||
className="bg-base p-3 h-screen md:min-w-[1024px] flex flex-col md:flex-row gap-3"
|
||||
>
|
||||
<Sidebar />
|
||||
|
||||
<div
|
||||
id="root-outlet"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative"
|
||||
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
49
frontend/src/utils/get-language-from-path.ts
Normal file
49
frontend/src/utils/get-language-from-path.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const getLanguageFromPath = (path: string): string => {
|
||||
const extension = path.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "js":
|
||||
case "jsx":
|
||||
return "javascript";
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return "typescript";
|
||||
case "py":
|
||||
return "python";
|
||||
case "html":
|
||||
return "html";
|
||||
case "css":
|
||||
return "css";
|
||||
case "json":
|
||||
return "json";
|
||||
case "md":
|
||||
return "markdown";
|
||||
case "yml":
|
||||
case "yaml":
|
||||
return "yaml";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "bash";
|
||||
case "dockerfile":
|
||||
return "dockerfile";
|
||||
case "rs":
|
||||
return "rust";
|
||||
case "go":
|
||||
return "go";
|
||||
case "java":
|
||||
return "java";
|
||||
case "cpp":
|
||||
case "cc":
|
||||
case "cxx":
|
||||
return "cpp";
|
||||
case "c":
|
||||
return "c";
|
||||
case "rb":
|
||||
return "ruby";
|
||||
case "php":
|
||||
return "php";
|
||||
case "sql":
|
||||
return "sql";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export default {
|
||||
"base-secondary": "#24272E", // lighter background
|
||||
danger: "#E76A5E",
|
||||
success: "#A5E75E",
|
||||
basic: "#9099AC", // light gray
|
||||
tertiary: "#454545", // gray, used for inputs
|
||||
"tertiary-light": "#B7BDC2", // lighter gray, used for borders and placeholder text
|
||||
content: "#ECEDEE", // light gray, used mostly for text
|
||||
|
||||
@@ -17,6 +17,8 @@ class CmdRunAction(Action):
|
||||
is_input: bool = False # if True, the command is an input to the running process
|
||||
thought: str = ''
|
||||
blocking: bool = False
|
||||
is_static: bool = False # if True, runs the command in a separate process
|
||||
cwd: str | None = None # current working directory, only used if is_static is True
|
||||
# If blocking is True, the command will be run in a blocking manner.
|
||||
# e.g., it will NOT return early due to soft timeout.
|
||||
hidden: bool = False
|
||||
|
||||
@@ -58,6 +58,7 @@ from openhands.events.serialization import event_from_dict, event_to_dict
|
||||
from openhands.runtime.browser import browse
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
|
||||
from openhands.runtime.utils.async_bash import AsyncBashSession
|
||||
from openhands.runtime.utils.bash import BashSession
|
||||
from openhands.runtime.utils.file_viewer import generate_file_viewer_html
|
||||
from openhands.runtime.utils.files import insert_lines, read_lines
|
||||
@@ -307,6 +308,16 @@ class ActionExecutor:
|
||||
async def run(
|
||||
self, action: CmdRunAction
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
if action.is_static:
|
||||
path = action.cwd or self._initial_cwd
|
||||
result = await AsyncBashSession.execute(action.command, path)
|
||||
obs = CmdOutputObservation(
|
||||
content=result.content,
|
||||
exit_code=result.exit_code,
|
||||
command=action.command,
|
||||
)
|
||||
return obs
|
||||
|
||||
assert self.bash_session is not None
|
||||
obs = await call_sync_from_async(self.bash_session.execute, action)
|
||||
return obs
|
||||
|
||||
@@ -58,6 +58,7 @@ from openhands.runtime.plugins import (
|
||||
VSCodeRequirement,
|
||||
)
|
||||
from openhands.runtime.utils.edit import FileEditRuntimeMixin
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
from openhands.utils.async_utils import (
|
||||
GENERAL_TIMEOUT,
|
||||
call_async_from_sync,
|
||||
@@ -111,6 +112,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
)
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
self.event_stream.subscribe(
|
||||
@@ -585,6 +589,35 @@ class Runtime(FileEditRuntimeMixin):
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
return {}
|
||||
|
||||
# ====================================================================
|
||||
# Git
|
||||
# ====================================================================
|
||||
|
||||
def _execute_shell_fn_git_handler(
|
||||
self, command: str, cwd: str | None
|
||||
) -> CommandResult:
|
||||
"""
|
||||
This function is used by the GitHandler to execute shell commands.
|
||||
"""
|
||||
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
if hasattr(obs, 'exit_code'):
|
||||
exit_code = obs.exit_code
|
||||
if hasattr(obs, 'content'):
|
||||
content = obs.content
|
||||
|
||||
return CommandResult(content=content, exit_code=exit_code)
|
||||
|
||||
def get_git_changes(self, cwd: str) -> list[dict[str, str]]:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_changes()
|
||||
|
||||
def get_git_diff(self, file_path: str, cwd: str) -> dict[str, str]:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
return ''
|
||||
|
||||
54
openhands/runtime/utils/async_bash.py
Normal file
54
openhands/runtime/utils/async_bash.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from openhands.runtime.base import CommandResult
|
||||
|
||||
|
||||
class AsyncBashSession:
|
||||
@staticmethod
|
||||
async def execute(command: str, work_dir: str) -> CommandResult:
|
||||
"""Execute a command in the bash session asynchronously."""
|
||||
work_dir = os.path.abspath(work_dir)
|
||||
|
||||
if not os.path.exists(work_dir):
|
||||
raise ValueError(f'Work directory {work_dir} does not exist.')
|
||||
|
||||
command = command.strip()
|
||||
if not command:
|
||||
return CommandResult(content='', exit_code=0)
|
||||
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=work_dir,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=30
|
||||
)
|
||||
output = stdout.decode('utf-8')
|
||||
|
||||
if stderr:
|
||||
output = stderr.decode('utf-8')
|
||||
print(f'!##! Error running command: {stderr.decode("utf-8")}')
|
||||
|
||||
return CommandResult(content=output, exit_code=process.returncode or 0)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
process.terminate()
|
||||
|
||||
# Allow a brief moment for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill() # Force kill if it doesn't terminate cleanly
|
||||
|
||||
return CommandResult(content='Command timed out.', exit_code=-1)
|
||||
|
||||
except Exception as e:
|
||||
return CommandResult(
|
||||
content=f'Error running command: {str(e)}', exit_code=-1
|
||||
)
|
||||
218
openhands/runtime/utils/git_handler.py
Normal file
218
openhands/runtime/utils/git_handler.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""
|
||||
Represents the result of a shell command execution.
|
||||
|
||||
Attributes:
|
||||
content (str): The output content of the command.
|
||||
exit_code (int): The exit code of the command execution.
|
||||
"""
|
||||
|
||||
content: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
class GitHandler:
|
||||
"""
|
||||
A handler for executing Git-related operations via shell commands.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execute_shell_fn: Callable[[str, str | None], CommandResult],
|
||||
):
|
||||
self.execute = execute_shell_fn
|
||||
self.cwd: str | None = None
|
||||
|
||||
def set_cwd(self, cwd: str):
|
||||
"""
|
||||
Sets the current working directory for Git operations.
|
||||
|
||||
Args:
|
||||
cwd (str): The directory path.
|
||||
"""
|
||||
self.cwd = cwd
|
||||
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
Returns:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
|
||||
Returns:
|
||||
str: The file content.
|
||||
"""
|
||||
output = self.execute(f'cat {file_path}', self.cwd)
|
||||
return output.content
|
||||
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
Args:
|
||||
ref (str): The Git reference to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the reference exists, otherwise False.
|
||||
"""
|
||||
cmd = f'git rev-parse --verify {ref}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{self._get_current_branch()})")'
|
||||
ref_default_branch = 'origin/' + self._get_current_branch()
|
||||
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
refs = [ref_non_default_branch, ref_default_branch, ref_new_repo]
|
||||
for ref in refs:
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
|
||||
return None
|
||||
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
Args:
|
||||
file_path (str): The file path in the repository.
|
||||
|
||||
Returns:
|
||||
str: The content of the file from the reference, or an empty string if unavailable.
|
||||
"""
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return ''
|
||||
|
||||
cmd = f'git show {ref}:{file_path}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
Returns:
|
||||
str: The name of the primary branch.
|
||||
"""
|
||||
cmd = 'git remote show origin | grep "HEAD branch"'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
"""
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
output = self.execute(diff_cmd, self.cwd)
|
||||
return output.content.splitlines()
|
||||
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
[{'status': 'A', 'path': path} for path in obs_list]
|
||||
if output.exit_code == 0
|
||||
else []
|
||||
)
|
||||
|
||||
def get_git_changes(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the directory is not a Git repository.
|
||||
"""
|
||||
if not self._is_git_repo():
|
||||
raise RuntimeError('Not a git repository')
|
||||
|
||||
changes_list = self._get_changed_files()
|
||||
result = parse_git_changes(changes_list)
|
||||
|
||||
# join with any untracked files
|
||||
result += self._get_untracked_files()
|
||||
return result
|
||||
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: A dictionary containing the original and modified content.
|
||||
"""
|
||||
modified = self._get_current_file_content(file_path)
|
||||
original = self._get_ref_content(file_path)
|
||||
|
||||
return {
|
||||
'modified': modified,
|
||||
'original': original,
|
||||
}
|
||||
|
||||
|
||||
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
|
||||
"""
|
||||
Parses the list of changed files and extracts their statuses and paths.
|
||||
|
||||
Args:
|
||||
changes_list (list[str]): List of changed file entries.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]]: Parsed list of file changes with statuses.
|
||||
"""
|
||||
result = []
|
||||
for line in changes_list:
|
||||
status = line[:2].strip()
|
||||
path = line[2:].strip()
|
||||
|
||||
# Get the first non-space character as the primary status
|
||||
primary_status = status.replace(' ', '')[0]
|
||||
result.append(
|
||||
{
|
||||
'status': primary_status,
|
||||
'path': path,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -21,9 +21,19 @@ from openhands.events.observation import (
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.auth import get_github_user_id, get_user_id
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
@@ -174,3 +184,101 @@ def zip_current_workspace(request: Request):
|
||||
status_code=500,
|
||||
detail='Failed to zip workspace',
|
||||
)
|
||||
|
||||
|
||||
@app.get('/git/changes')
|
||||
async def git_changes(request: Request, conversation_id: str):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
logger.info(f'Getting git changes in {cwd}')
|
||||
|
||||
try:
|
||||
changes = await call_sync_from_async(runtime.get_git_changes, cwd)
|
||||
return changes
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Runtime unavailable: {e}')
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Error getting changes: {e}'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting changes: {e}')
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': str(e)},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/git/diff')
|
||||
async def git_diff(request: Request, path: str, conversation_id: str):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
try:
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path, cwd)
|
||||
return diff
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Error getting diff: {e}')
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': f'Error getting diff: {e}'},
|
||||
)
|
||||
|
||||
|
||||
async def get_cwd(
|
||||
conversation_store: ConversationStore,
|
||||
conversation_id: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
):
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
conversation_info = await _get_conversation_info(metadata, is_running)
|
||||
|
||||
cwd = workspace_mount_path_in_sandbox
|
||||
if conversation_info and conversation_info.selected_repository:
|
||||
repo_dir = conversation_info.selected_repository.split('/')[-1]
|
||||
cwd = os.path.join(cwd, repo_dir)
|
||||
|
||||
return cwd
|
||||
|
||||
|
||||
async def _get_conversation_info(
|
||||
conversation: ConversationMetadata,
|
||||
is_running: bool,
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
title = conversation.title
|
||||
if not title:
|
||||
title = f'Conversation {conversation.conversation_id[:5]}'
|
||||
return ConversationInfo(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=conversation.last_updated_at,
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
else ConversationStatus.STOPPED,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error loading conversation {conversation.conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -98,6 +98,8 @@ def test_cmd_run_action_serialization_deserialization():
|
||||
'thought': '',
|
||||
'hidden': False,
|
||||
'confirmation_state': ActionConfirmationStatus.CONFIRMED,
|
||||
'is_static': False,
|
||||
'cwd': None,
|
||||
},
|
||||
}
|
||||
serialization_deserialization(original_action_dict, CmdRunAction)
|
||||
|
||||
@@ -374,6 +374,8 @@ async def test_unsafe_bash_command(temp_dir: str):
|
||||
'is_input': False,
|
||||
'hidden': False,
|
||||
'confirmation_state': ActionConfirmationStatus.CONFIRMED,
|
||||
'is_static': False,
|
||||
'cwd': None,
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user