Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/four-needles-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@roomote/web": minor
---

Add a diff tab to the environment editor. Shows side by side merge view to compare new or existing versions
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
"@aws-sdk/client-s3": "3.1000.0",
"@aws-sdk/s3-request-presigner": "3.1000.0",
"@better-auth/drizzle-adapter": "^1.6.22",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/merge": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@hookform/resolvers": "^5.2.2",
"@linear/sdk": "^68.0.0",
"@melloware/react-logviewer": "^6.4.1",
Expand Down Expand Up @@ -74,6 +77,7 @@
"@trpc/client": "^11.15.0",
"@trpc/server": "^11.15.0",
"@trpc/tanstack-react-query": "^11.15.0",
"@uiw/codemirror-theme-github": "^4.25.11",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
Expand All @@ -86,6 +90,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.0",
"cron-parser": "5.6.1",
"cronstrue": "^3.24.0",
"date-fns": "^4.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { type SelectedRepositorySummary } from './EnvironmentDefinitionAgentTask
import { EnvironmentRepositorySelector } from './EnvironmentRepositorySelector';
import { UpdateGitHubReposHint } from './UpdateGitHubReposHint';
import {
type YamlEditorTab,
type YamlEnvironmentEditorHandle,
YamlEnvironmentEditor,
} from './YamlEnvironmentEditor';
Expand Down Expand Up @@ -87,6 +88,9 @@ export function EditEnvironmentPage({
null,
);
const [editorResetKey, setEditorResetKey] = useState(0);
// Lifted above YamlMasterView/YamlEnvironmentEditor so it survives the
// `editorResetKey`-driven remount that happens when switching versions.
const [yamlEditorTab, setYamlEditorTab] = useState<YamlEditorTab>('editor');
const [selectedVersionValue, setSelectedVersionValue] = useState<string>(
CURRENT_VERSION_VALUE,
);
Expand Down Expand Up @@ -121,6 +125,7 @@ export function EditEnvironmentPage({
setAgentChangeRequest('');
setSelectedModelId(undefined);
setIsLoadingVersion(false);
setYamlEditorTab('editor');
}, [environmentId]);

useEffect(() => {
Expand Down Expand Up @@ -149,6 +154,7 @@ export function EditEnvironmentPage({
setAgentChangeRequest('');
setSelectedModelId(undefined);
setActiveView('yaml');
setYamlEditorTab('editor');
};

const resetLoadedVersionState = (shouldResetEditor: boolean) => {
Expand Down Expand Up @@ -329,6 +335,17 @@ export function EditEnvironmentPage({
[configVersionsQuery.data],
);
const showVersionSelector = versionOptions.length >= 2;
// `listConfigVersions` is sorted newest-first, so the first entry is the
// version number of the currently saved config.
const currentVersionNumber = configVersionsQuery.data?.[0]?.version;
const diffOriginalLabel =
currentVersionNumber != null
? `Saved configuration (Version ${currentVersionNumber})`
: undefined;
const diffModifiedLabel =
selectedVersionValue !== CURRENT_VERSION_VALUE
? `Editing Version ${selectedVersionValue}`
: undefined;

return (
<>
Expand Down Expand Up @@ -406,6 +423,10 @@ export function EditEnvironmentPage({
versionOptions={versionOptions}
isLoadingVersion={isLoadingVersion}
onSelectVersion={(value) => void handleVersionSelect(value)}
activeTab={yamlEditorTab}
onActiveTabChange={setYamlEditorTab}
diffOriginalLabel={diffOriginalLabel}
diffModifiedLabel={diffModifiedLabel}
/>
) : (
<AgentMasterView
Expand Down Expand Up @@ -457,6 +478,10 @@ function YamlMasterView({
versionOptions,
isLoadingVersion,
onSelectVersion,
activeTab,
onActiveTabChange,
diffOriginalLabel,
diffModifiedLabel,
}: {
editorRef: React.RefObject<YamlEnvironmentEditorHandle | null>;
initialConfig: EnvironmentConfig | undefined;
Expand All @@ -477,6 +502,10 @@ function YamlMasterView({
versionOptions: Array<{ value: string; label: string }>;
isLoadingVersion: boolean;
onSelectVersion: (value: string) => void;
activeTab: YamlEditorTab;
onActiveTabChange: (tab: YamlEditorTab) => void;
diffOriginalLabel?: string;
diffModifiedLabel?: string;
}) {
return (
<div id="yaml-editor">
Expand All @@ -491,6 +520,10 @@ function YamlMasterView({
isSaving={isSaving}
warnings={warnings}
hideActions
activeTab={activeTab}
onActiveTabChange={onActiveTabChange}
diffOriginalLabel={diffOriginalLabel}
diffModifiedLabel={diffModifiedLabel}
/>

<div className="flex flex-col gap-4 border-t pt-4 md:flex-row md:items-center md:justify-between">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use client';

import { useEffect, useRef } from 'react';
import { useTheme } from 'next-themes';

import { basicSetup, EditorView } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { yaml } from '@codemirror/lang-yaml';
import { MergeView } from '@codemirror/merge';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';

import { Lock, Pencil } from '@/components/system';
import { cn } from '@/lib/utils';

// githubLight/githubDark already provide full editor chrome (background,
// gutters, selection, syntax colors) matching the same GitHub palette the
// shiki-based code blocks elsewhere in the app use.
const fontTheme = EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '0.8rem',
},
});

function sideExtensions(dark: boolean) {
return [basicSetup, yaml(), fontTheme, dark ? githubDark : githubLight];
}

interface EnvironmentDiffMergeViewProps {
/** Saved/baseline YAML, shown read-only on the left. */
original: string;
/** Editable draft YAML, shown on the right. */
modified: string;
onModifiedChange: (value: string) => void;
className?: string;
/** Label for the left (read-only) pane. Defaults to "Saved configuration". */
originalLabel?: string;
/** Label for the right (editable) pane. Defaults to "Editing". */
modifiedLabel?: string;
}

export function EnvironmentDiffMergeView({
original,
modified,
onModifiedChange,
className,
originalLabel = 'Saved configuration',
modifiedLabel = 'Editing',
}: EnvironmentDiffMergeViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mergeViewRef = useRef<MergeView | null>(null);
const lastEmittedRef = useRef(modified);
const onModifiedChangeRef = useRef(onModifiedChange);
onModifiedChangeRef.current = onModifiedChange;

const { resolvedTheme } = useTheme();
const dark = resolvedTheme === 'dark';

useEffect(() => {
if (!containerRef.current) {
return;
}

const mergeView = new MergeView({
parent: containerRef.current,
a: {
doc: original,
extensions: [...sideExtensions(dark), EditorState.readOnly.of(true)],
},
b: {
doc: modified,
extensions: [
...sideExtensions(dark),
EditorView.updateListener.of((update) => {
if (!update.docChanged) {
return;
}

const value = update.state.doc.toString();
lastEmittedRef.current = value;
onModifiedChangeRef.current(value);
}),
],
},
gutter: true,
highlightChanges: true,
});

mergeViewRef.current = mergeView;

return () => {
mergeView.destroy();
mergeViewRef.current = null;
};
// A theme change is the only prop that should tear down and rebuild the whole view;
// `original`/`modified` updates are pushed into the live view by the effects below instead,
// so they don't clobber in-progress edits or cursor position on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dark]);

useEffect(() => {
const mergeView = mergeViewRef.current;

if (!mergeView) {
return;
}

const currentA = mergeView.a.state.doc.toString();

if (currentA !== original) {
mergeView.a.dispatch({
changes: { from: 0, to: currentA.length, insert: original },
});
}
}, [original]);

useEffect(() => {
const mergeView = mergeViewRef.current;

if (!mergeView || modified === lastEmittedRef.current) {
return;
}

const currentB = mergeView.b.state.doc.toString();

if (currentB !== modified) {
mergeView.b.dispatch({
changes: { from: 0, to: currentB.length, insert: modified },
});
}

lastEmittedRef.current = modified;
}, [modified]);

return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-lg border',
className,
)}
>
<div className="grid grid-cols-2 shrink-0 border-b bg-muted/80 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 border-r px-3 py-1.5">
<Lock className="size-3" />
{originalLabel}
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Pencil className="size-3" />
{modifiedLabel}
</div>
</div>
<div
ref={containerRef}
className={cn(
'min-h-0 flex-1 overflow-hidden',
'[&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto',
'[&_.cm-editor]:h-full',
)}
/>
</div>
);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading