From 34989f8e964af4873098cf74b4efb32aea1089a9 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:12:25 +0400 Subject: [PATCH] feat: Diff UI (#6934) --- .../components/chat/chat-interface.test.tsx | 2 +- frontend/src/api/open-hands.ts | 22 ++ frontend/src/api/open-hands.types.ts | 12 + .../features/diff-viewer/file-diff-viewer.tsx | 172 ++++++++++++++ frontend/src/components/layout/container.tsx | 12 +- frontend/src/components/layout/nav-tab.tsx | 14 +- frontend/src/context/ws-client-provider.tsx | 58 ++++- frontend/src/hooks/query/use-get-diff.ts | 22 ++ .../src/hooks/query/use-get-git-changes.ts | 63 +++++ frontend/src/i18n/declaration.ts | 5 + frontend/src/i18n/translation.json | 75 ++++++ frontend/src/icons/chveron-up.svg | 3 + frontend/src/routes.ts | 3 +- frontend/src/routes/conversation.tsx | 8 +- frontend/src/routes/editor-tab.tsx | 51 +--- frontend/src/routes/editor.tsx | 50 ++++ frontend/src/routes/root-layout.tsx | 4 +- frontend/src/utils/get-language-from-path.ts | 49 ++++ frontend/tailwind.config.js | 1 + openhands/events/action/commands.py | 2 + openhands/runtime/action_execution_server.py | 11 + openhands/runtime/base.py | 33 +++ openhands/runtime/utils/async_bash.py | 54 +++++ openhands/runtime/utils/git_handler.py | 218 ++++++++++++++++++ openhands/server/routes/files.py | 108 +++++++++ tests/unit/test_action_serialization.py | 2 + tests/unit/test_security.py | 2 + 27 files changed, 993 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/features/diff-viewer/file-diff-viewer.tsx create mode 100644 frontend/src/hooks/query/use-get-diff.ts create mode 100644 frontend/src/hooks/query/use-get-git-changes.ts create mode 100644 frontend/src/icons/chveron-up.svg create mode 100644 frontend/src/routes/editor.tsx create mode 100644 frontend/src/utils/get-language-from-path.ts create mode 100644 openhands/runtime/utils/async_bash.py create mode 100644 openhands/runtime/utils/git_handler.py diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 9411ec473d..b010ab2dc3 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -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"; diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 931a9e79ed..ed5bbbba31 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -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 { + const { data } = await openHands.get( + `/api/conversations/${conversationId}/git/changes`, + ); + return data; + } + + static async getGitChangeDiff( + conversationId: string, + path: string, + ): Promise { + const { data } = await openHands.get( + `/api/conversations/${conversationId}/git/diff`, + { + params: { path }, + }, + ); + return data; + } } export default OpenHands; diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 74c3aa180a..c175c8c42d 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -83,3 +83,15 @@ export interface ResultSet { 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; +} diff --git a/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx b/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx new file mode 100644 index 0000000000..0d66130cf2 --- /dev/null +++ b/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx @@ -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 ( +
+
+
+ ); +} + +const STATUS_MAP: Record = { + 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(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 = {status}; + } else { + const StatusIcon = status; // now it's recognized as a component + statusIcon = ; + } + + const isFetchingData = isLoading || isRefetching; + + return ( +
+
setIsCollapsed((prev) => !prev)} + > + + {isFetchingData && } + {!isFetchingData && statusIcon} + {filePath} + + +
+ {isSuccess && !isCollapsed && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/container.tsx b/frontend/src/components/layout/container.tsx index 541b57cd1d..14c55b3e98 100644 --- a/frontend/src/components/layout/container.tsx +++ b/frontend/src/components/layout/container.tsx @@ -9,6 +9,7 @@ interface ContainerProps { to: string; icon?: React.ReactNode; isBeta?: boolean; + isLoading?: boolean; }[]; children: React.ReactNode; className?: React.HTMLAttributes["className"]; @@ -29,8 +30,15 @@ export function Container({ > {labels && (
- {labels.map(({ label: l, to, icon, isBeta }) => ( - + {labels.map(({ label: l, to, icon, isBeta, isLoading }) => ( + ))}
)} diff --git a/frontend/src/components/layout/nav-tab.tsx b/frontend/src/components/layout/nav-tab.tsx index 5ba0fc1bf8..4ba2b8d3b8 100644 --- a/frontend/src/components/layout/nav-tab.tsx +++ b/frontend/src/components/layout/nav-tab.tsx @@ -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 ( {({ isActive }) => ( <> -
{icon}
- {label} - {isBeta && } +
+
{icon}
+ {label} + {isBeta && } +
+ + {isLoading && } )}
diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index ad7965cdce..87b4b6a565 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -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) { + const queryClient = useQueryClient(); const sioRef = React.useRef(null); const [status, setStatus] = React.useState( WsClientProviderStatus.DISCONNECTED, @@ -128,9 +145,46 @@ export function WsClientProvider({ } function handleMessage(event: Record) { - 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([ + "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; diff --git a/frontend/src/hooks/query/use-get-diff.ts b/frontend/src/hooks/query/use-get-diff.ts new file mode 100644 index 0000000000..2f7f8db425 --- /dev/null +++ b/frontend/src/hooks/query/use-get-diff.ts @@ -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 + }); +}; diff --git a/frontend/src/hooks/query/use-get-git-changes.ts b/frontend/src/hooks/query/use-get-git-changes.ts new file mode 100644 index 0000000000..cf2e99ba50 --- /dev/null +++ b/frontend/src/hooks/query/use-get-git-changes.ts @@ -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([]); + const previousDataRef = React.useRef(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, + }; +}; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index dbffe78f02..c93c283efa 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index f58d16268e..b5feee2294 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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..." } } diff --git a/frontend/src/icons/chveron-up.svg b/frontend/src/icons/chveron-up.svg new file mode 100644 index 0000000000..32e0f65a6f --- /dev/null +++ b/frontend/src/icons/chveron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 72bed1969f..9c8f1e4bd8 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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"), diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index 3b2d108821..21b1c1cdb8 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.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: , + }, + { + label: t(I18nKey.WORKSPACE$TITLE), + to: "workspace", icon: , }, { diff --git a/frontend/src/routes/editor-tab.tsx b/frontend/src/routes/editor-tab.tsx index 72e1bfb66b..1788cf206e 100644 --- a/frontend/src/routes/editor-tab.tsx +++ b/frontend/src/routes/editor-tab.tsx @@ -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(); diff --git a/frontend/src/routes/editor.tsx b/frontend/src/routes/editor.tsx new file mode 100644 index 0000000000..1bd507606b --- /dev/null +++ b/frontend/src/routes/editor.tsx @@ -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 ( +
+ {children} +
+ ); +} + +function EditorScreen() { + const { t } = useTranslation(); + const { data: gitChanges, isSuccess, isError, error } = useGetGitChanges(); + + const isNotGitRepoError = + error && retrieveAxiosErrorMessage(error) === "Not a git repository"; + + return ( +
+ {!isNotGitRepoError && error && ( + {retrieveAxiosErrorMessage(error)} + )} + {isNotGitRepoError && ( + + {t(I18nKey.DIFF_VIEWER$NOT_A_GIT_REPO)} +
+ {t(I18nKey.DIFF_VIEWER$ASK_OH)} +
+ )} + + {!isError && gitChanges?.length === 0 && ( + {t(I18nKey.DIFF_VIEWER$NO_CHANGES)} + )} + {isSuccess && + gitChanges.map((change) => ( + + ))} +
+ ); +} + +export default EditorScreen; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index c7db0d160a..b4a734bde0 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -118,13 +118,13 @@ export default function MainApp() { return (
diff --git a/frontend/src/utils/get-language-from-path.ts b/frontend/src/utils/get-language-from-path.ts new file mode 100644 index 0000000000..5d5b3c7163 --- /dev/null +++ b/frontend/src/utils/get-language-from-path.ts @@ -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"; + } +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index efbe67a955..a56f004f36 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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 diff --git a/openhands/events/action/commands.py b/openhands/events/action/commands.py index ab5c77de29..395531ae49 100644 --- a/openhands/events/action/commands.py +++ b/openhands/events/action/commands.py @@ -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 diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index c71bf00db1..fe9ca8897e 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -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 diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index e311d68640..da8aadf31b 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -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 '' diff --git a/openhands/runtime/utils/async_bash.py b/openhands/runtime/utils/async_bash.py new file mode 100644 index 0000000000..b7ac3823e5 --- /dev/null +++ b/openhands/runtime/utils/async_bash.py @@ -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 + ) diff --git a/openhands/runtime/utils/git_handler.py b/openhands/runtime/utils/git_handler.py new file mode 100644 index 0000000000..bfeb884516 --- /dev/null +++ b/openhands/runtime/utils/git_handler.py @@ -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 diff --git a/openhands/server/routes/files.py b/openhands/server/routes/files.py index 0372b7f694..7b9d8150a5 100644 --- a/openhands/server/routes/files.py +++ b/openhands/server/routes/files.py @@ -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 diff --git a/tests/unit/test_action_serialization.py b/tests/unit/test_action_serialization.py index f0448baa09..8c954b746a 100644 --- a/tests/unit/test_action_serialization.py +++ b/tests/unit/test_action_serialization.py @@ -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) diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index e58434586b..058a0b8303 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -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, }, ), ),