feat: Diff UI (#6934)

This commit is contained in:
sp.wack
2025-04-17 16:12:25 +04:00
committed by GitHub
parent 9274664302
commit 34989f8e96
27 changed files with 993 additions and 63 deletions

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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;

View 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
});
};

View 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,
};
};

View File

@@ -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",
}

View File

@@ -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..."
}
}

View 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

View File

@@ -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"),

View File

@@ -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 />,
},
{

View File

@@ -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();

View 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;

View File

@@ -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>

View 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";
}
};

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ''

View 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
)

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
},
),
),