Skip to content
Open
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
106 changes: 106 additions & 0 deletions assets/js/collaborative-editor/components/CollaborativeMonaco.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import { type Monaco, MonacoEditor, setTheme } from '../../monaco';
import { addKeyboardShortcutOverrides } from '../../monaco/keyboard-overrides';
import { useHandleDiffDismissed } from '../contexts/MonacoRefContext';

import createCompletionProvider from '../../editor/magic-completion';

import { LoadingIndicator } from './common/LoadingIndicator';
import { Cursors } from './Cursors';
import { Tooltip } from './Tooltip';
import { loadDTS, type Lib } from '../utils/loadDTS';

export interface MonacoHandle {
showDiff: (originalCode: string, modifiedCode: string) => void;
Expand All @@ -33,6 +37,7 @@ interface CollaborativeMonacoProps {
ytext: Y.Text;
awareness: Awareness;
adaptor?: string;
metadata?: object | null;
disabled?: boolean;
className?: string;
options?: editor.IStandaloneEditorConstructionOptions;
Expand All @@ -46,6 +51,7 @@ export const CollaborativeMonaco = forwardRef<
ytext,
awareness,
adaptor = 'common',
metadata,
disabled = false,
className,
options = {},
Expand All @@ -57,6 +63,10 @@ export const CollaborativeMonaco = forwardRef<
const bindingRef = useRef<MonacoBinding>();
const [editorReady, setEditorReady] = useState(false);

// Type definitions state
const [lib, setLib] = useState<Lib[]>();
const [loading, setLoading] = useState(false);

// Get callback from context to notify when diff is dismissed
const handleDiffDismissed = useHandleDiffDismissed();

Expand All @@ -66,6 +76,9 @@ export const CollaborativeMonaco = forwardRef<
const containerRef = useRef<HTMLDivElement | null>(null);
const diffContainerRef = useRef<HTMLDivElement | null>(null);

// Overflow widgets container ref
const overflowNodeRef = useRef<HTMLDivElement>();

// Base editor options shared between main and diff editors
const baseEditorOptions: editor.IStandaloneEditorConstructionOptions =
useMemo(
Expand All @@ -82,6 +95,24 @@ export const CollaborativeMonaco = forwardRef<
insertSpaces: true,
automaticLayout: true,
fixedOverflowWidgets: true,
dragAndDrop: false,
lineNumbersMinChars: 3,
overviewRulerLanes: 0,
overviewRulerBorder: false,
codeLens: false,
wordBasedSuggestions: 'off',
fontFamily: 'Fira Code VF',
fontLigatures: true,
showFoldingControls: 'always',
suggest: {
showModules: true,
showKeywords: false,
showFiles: false,
showClasses: false,
showInterfaces: false,
showConstructors: false,
showDeprecated: false,
},
}),
[]
);
Expand All @@ -99,6 +130,27 @@ export const CollaborativeMonaco = forwardRef<

addKeyboardShortcutOverrides(editor, monaco);

// Create overflow widgets container for suggestions/tooltips
if (!overflowNodeRef.current) {
const overflowNode = document.createElement('div');
overflowNode.className = 'monaco-editor widgets-overflow-container';
document.body.appendChild(overflowNode);
overflowNodeRef.current = overflowNode;

// Update editor options with overflow container
// @ts-ignore - overflowWidgetsDomNode exists but isn't in updateOptions type
editor.updateOptions({
overflowWidgetsDomNode: overflowNode,
fixedOverflowWidgets: true,
});
}

// Configure TypeScript compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
noLib: true,
});

// Don't create binding here - let the useEffect handle it
// This ensures binding is created/updated whenever ytext changes
},
Expand Down Expand Up @@ -210,6 +262,55 @@ export const CollaborativeMonaco = forwardRef<
};
}, []);

// Load type definitions when adaptor changes
useEffect(() => {
if (adaptor) {
setLoading(true);
setLib([]); // instantly clear intelligence
loadDTS(adaptor)
.then(l => {
setLib(l);
})
.finally(() => {
setLoading(false);
});
}
}, [adaptor]);

// Set extra libs on Monaco when lib changes
useEffect(() => {
if (monacoRef.current && lib) {
monacoRef.current.languages.typescript.javascriptDefaults.setExtraLibs(
lib
);
}
}, [lib]);

// Register metadata completion provider
useEffect(() => {
if (monacoRef.current && metadata) {
const provider =
monacoRef.current.languages.registerCompletionItemProvider(
'javascript',
createCompletionProvider(monacoRef.current, metadata)
);
return () => {
provider.dispose();
};
}
}, [metadata]);

// Cleanup overflow node on unmount
useEffect(() => {
return () => {
if (overflowNodeRef.current) {
overflowNodeRef.current.parentNode?.removeChild(
overflowNodeRef.current
);
}
};
}, []);

// showDiff function - creates diff editor overlay
const showDiff = useCallback(
(originalCode: string, modifiedCode: string) => {
Expand Down Expand Up @@ -354,10 +455,15 @@ export const CollaborativeMonaco = forwardRef<

return (
<div className={cn('relative', className || 'h-full w-full')}>
{/* Loading indicator */}
<div className="relative z-10 h-0 overflow-visible text-right text-xs text-white">
{loading && <LoadingIndicator text="Loading types" />}
</div>
{/* Standard editor container */}
<div ref={containerRef} className="h-full w-full">
<Cursors />
<MonacoEditor
defaultPath="/job.js" // Required for magic completion
onMount={handleOnMount}
options={editorOptions}
theme="default"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Spinner } from './Spinner';

interface LoadingIndicatorProps {
text?: string;
}

/**
* LoadingIndicator - Text with spinner for loading states
*
* Displays a text message alongside a spinning icon.
* Used for Monaco editor type definitions loading.
*
* @example
* <LoadingIndicator text="Loading types" />
* <LoadingIndicator text="Loading workflow" />
*/
export function LoadingIndicator({ text = 'Loading' }: LoadingIndicatorProps) {
return (
<div className="inline-block p-2">
<Spinner size="md" className="inline-block mr-2" />
<span>{text}</span>
</div>
);
}
35 changes: 35 additions & 0 deletions assets/js/collaborative-editor/components/common/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { cn } from '#/utils/cn';

interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}

/**
* Spinner - Reusable loading spinner using heroicons
*
* Uses the `hero-arrow-path` icon with Tailwind's `animate-spin` class,
* following the established pattern across the collaborative-editor.
*
* @example
* <Spinner size="md" />
* <Spinner size="sm" className="text-primary-500" />
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
const sizeClasses = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};

return (
<span
className={cn(
'hero-arrow-path animate-spin',
sizeClasses[size],
className
)}
aria-label="Loading"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
useJobMatchesRun,
} from '../../hooks/useHistory';
import { useRunRetry } from '../../hooks/useRunRetry';
import { useMetadata } from '../../hooks/useMetadata';
import { useRunRetryShortcuts } from '../../hooks/useRunRetryShortcuts';
import { useSession } from '../../hooks/useSession';
import { useProject } from '../../hooks/useSessionContext';
Expand Down Expand Up @@ -130,6 +131,7 @@ export function FullScreenIDE({
const { selectJob, saveWorkflow } = useWorkflowActions();
const { selectStep } = useHistoryCommands();
const { job: currentJob, ytext: currentJobYText } = useCurrentJob();
const { metadata, isLoading: isMetadataLoading } = useMetadata();
const awareness = useSession(selectAwareness);
const { canSave } = useCanSave();

Expand Down Expand Up @@ -1015,6 +1017,7 @@ export function FullScreenIDE({
ytext={currentJobYText}
awareness={awareness}
adaptor={currentJob.adaptor || 'common'}
metadata={metadata}
disabled={!canSave}
className="h-full w-full"
options={{
Expand Down Expand Up @@ -1114,7 +1117,7 @@ export function FullScreenIDE({
{selectedDocsTab === 'metadata' && (
<Metadata
adaptor={currJobAdaptor}
metadata={null}
metadata={isMetadataLoading ? true : metadata}
/>
)}
</div>
Expand Down
34 changes: 16 additions & 18 deletions assets/js/collaborative-editor/contexts/StoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ import {
type HistoryStoreInstance,
} from '../stores/createHistoryStore';
import type { RunStepsData } from '../types/history';
import {
createMetadataStore,
type MetadataStoreInstance,
} from '../stores/createMetadataStore';
import {
createSessionContextStore,
type SessionContextStoreInstance,
Expand All @@ -81,6 +85,7 @@ import { generateUserColor } from '../utils/userColor';
export interface StoreContextValue {
adaptorStore: AdaptorStoreInstance;
credentialStore: CredentialStoreInstance;
metadataStore: MetadataStoreInstance;
awarenessStore: AwarenessStoreInstance;
workflowStore: WorkflowStoreInstance;
sessionContextStore: SessionContextStoreInstance;
Expand Down Expand Up @@ -126,6 +131,7 @@ export const StoreProvider = ({ children }: StoreProviderProps) => {
return {
adaptorStore: createAdaptorStore(),
credentialStore: createCredentialStore(),
metadataStore: createMetadataStore(),
awarenessStore: createAwarenessStore(),
workflowStore: createWorkflowStore(),
sessionContextStore: createSessionContextStore(isNewWorkflow),
Expand Down Expand Up @@ -189,25 +195,17 @@ export const StoreProvider = ({ children }: StoreProviderProps) => {
// Connect stores when provider is ready
useEffect(() => {
if (session.provider && session.isConnected) {
const cleanup1 = stores.adaptorStore._connectChannel(session.provider);
const cleanup2 = stores.credentialStore._connectChannel(session.provider);
const cleanup3 = stores.sessionContextStore._connectChannel(
session.provider
);
const cleanup4 = stores.historyStore._connectChannel(session.provider);
const cleanup5 = stores.aiAssistantStore._connectChannel(
session.provider
);

return () => {
cleanup1();
cleanup2();
cleanup3();
cleanup4();
cleanup5();
};
const connections = [
stores.adaptorStore,
stores.credentialStore,
stores.metadataStore,
stores.sessionContextStore,
stores.historyStore,
stores.aiAssistantStore,
].map(store => store._connectChannel(session.provider!));

return () => connections.forEach(cleanup => cleanup());
}
return undefined;
}, [session.provider, session.isConnected, stores]);

// Connect/disconnect workflowStore Y.Doc when session changes
Expand Down
Loading