diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7413dc33272..e3c693e0dcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ permissions: jobs: test-build: name: Test and Build + if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request' uses: ./.github/workflows/test-build.yml secrets: inherit @@ -45,11 +46,66 @@ jobs: echo "ℹ️ Not a release commit" fi - # Build AMD64 images and push to ECR immediately (+ GHCR for main) + # Dev: build all 3 images for ECR only (no GHCR, no ARM64) + build-dev: + name: Build Dev ECR + needs: [detect-version] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - dockerfile: ./docker/app.Dockerfile + ecr_repo_secret: ECR_APP + - dockerfile: ./docker/db.Dockerfile + ecr_repo_secret: ECR_MIGRATIONS + - dockerfile: ./docker/realtime.Dockerfile + ecr_repo_secret: ECR_REALTIME + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.DEV_AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: useblacksmith/setup-docker-builder@v1 + + - name: Build and push + uses: useblacksmith/build-push-action@v2 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ${{ steps.login-ecr.outputs.registry }}/${{ secrets[matrix.ecr_repo_secret] }}:dev + provenance: false + sbom: false + + # Main/staging: build AMD64 images and push to ECR + GHCR build-amd64: name: Build AMD64 - needs: [detect-version] - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') + needs: [test-build, detect-version] + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -75,8 +131,8 @@ jobs: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }} + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} - name: Login to Amazon ECR id: login-ecr @@ -106,26 +162,20 @@ jobs: ECR_REPO="${{ secrets[matrix.ecr_repo_secret] }}" GHCR_IMAGE="${{ matrix.ghcr_image }}" - # ECR tags (always build for ECR) if [ "${{ github.ref }}" = "refs/heads/main" ]; then ECR_TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then - ECR_TAG="dev" else ECR_TAG="staging" fi ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}" - # Build tags list TAGS="${ECR_IMAGE}" - # Add GHCR tags only for main branch if [ "${{ github.ref }}" = "refs/heads/main" ]; then GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" - # Add version tag if this is a release commit if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then VERSION="${{ needs.detect-version.outputs.version }}" GHCR_VERSION="${GHCR_IMAGE}:${VERSION}-amd64" @@ -256,6 +306,14 @@ jobs: docker manifest push "${IMAGE_BASE}:${VERSION}" fi + # Run database migrations for dev + migrate-dev: + name: Migrate Dev DB + needs: [build-dev] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + uses: ./.github/workflows/migrations.yml + secrets: inherit + # Check if docs changed check-docs-changes: name: Check Docs Changes diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index db084926861..245023ab86f 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -38,5 +38,5 @@ jobs: - name: Apply migrations working-directory: ./packages/db env: - DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || secrets.STAGING_DATABASE_URL }} + DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || github.ref == 'refs/heads/dev' && secrets.DEV_DATABASE_URL || secrets.STAGING_DATABASE_URL }} run: bunx drizzle-kit migrate --config=./drizzle.config.ts \ No newline at end of file diff --git a/README.md b/README.md index 93dbc8ba00e..de6befd2a8f 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,6 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) -#### Background worker note - -The Docker Compose stack starts a dedicated worker container by default. If `REDIS_URL` is not configured, the worker will start, log that it is idle, and do no queue processing. This is expected. Queue-backed API, webhook, and schedule execution requires Redis; installs without Redis continue to use the inline execution path. - Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. ### Self-hosted: Manual Setup @@ -123,12 +119,10 @@ cd packages/db && bun run db:migrate 5. Start development servers: ```bash -bun run dev:full # Starts Next.js app, realtime socket server, and the BullMQ worker +bun run dev:full # Starts Next.js app and realtime socket server ``` -If `REDIS_URL` is not configured, the worker will remain idle and execution continues inline. - -Or run separately: `bun run dev` (Next.js), `cd apps/sim && bun run dev:sockets` (realtime), and `cd apps/sim && bun run worker` (BullMQ worker). +Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). ## Copilot API Keys diff --git a/apps/sim/app/(landing)/components/features/components/features-preview.tsx b/apps/sim/app/(landing)/components/features/components/features-preview.tsx index e485396a7e6..ae603009bc5 100644 --- a/apps/sim/app/(landing)/components/features/components/features-preview.tsx +++ b/apps/sim/app/(landing)/components/features/components/features-preview.tsx @@ -2,8 +2,8 @@ import { type SVGProps, useEffect, useRef, useState } from 'react' import { AnimatePresence, motion, useInView } from 'framer-motion' -import ReactMarkdown, { type Components } from 'react-markdown' -import remarkGfm from 'remark-gfm' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { ChevronDown } from '@/components/emcn' import { Database, File, Library, Table } from '@/components/emcn/icons' import { @@ -557,8 +557,8 @@ The team agreed to prioritize the new onboarding flow. Key decisions: Follow up with engineering on the timeline for the API v2 migration. Draft the proposal for the board meeting next week.` -const MD_COMPONENTS: Components = { - h1: ({ children }) => ( +const MD_COMPONENTS = { + h1: ({ children }: { children?: React.ReactNode }) => (
), - h2: ({ children }) => ( + h2: ({ children }: { children?: React.ReactNode }) => (
{children}
, + p: ({ children }: { children?: React.ReactNode }) => ( +{children}
+ ), } function MockFullFiles() { @@ -618,9 +624,9 @@ function MockFullFiles() { transition={{ duration: 0.4, delay: 0.5 }} >
+ inlineCode: ({ children }) => (
+
{children}
),
@@ -212,7 +211,7 @@ export default function ChangelogList({ initialEntries }: Props) {
}}
>
{cleanMarkdown(entry.content)}
-
- {children}
-
- )
- }
- return (
-
- {children}
-
- )
- },
+ inlineCode: ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
blockquote: ({ children }: React.HTMLAttributes@@ -215,9 +192,9 @@ const MarkdownRenderer = memo(function MarkdownRenderer({ return (-) }) diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 40ad9722a8c..b311837cdd8 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -14,7 +14,8 @@ import { User, } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import ReactMarkdown from 'react-markdown' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { Breadcrumb, Button, @@ -875,7 +876,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template About this Workflow+ +{processedContent} - -)} @@ -1056,7 +1058,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template {/* Creator bio */} {template.creator.details?.about && (( +@@ -913,16 +915,16 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template ), li: ({ children }) =>
{children} , - code: ({ inline, children }: any) => - inline ? ( -- {children} -- ) : ( -- {children} -- ), + inlineCode: ({ children }) => ( ++ {children} ++ ), + code: ({ children }) => ( ++ {children} ++ ), a: ({ href, children }) => ( {template.details.about} --)} diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 77607befa95..5cdd0523a6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,6 +10,7 @@ import { ModalContent, ModalFooter, ModalHeader, + TagIcon, Textarea, ThumbsDown, ThumbsUp, @@ -46,13 +47,16 @@ interface MessageActionsProps { content: string chatId?: string userQuery?: string + requestId?: string } -export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) { +export function MessageActions({ content, chatId, userQuery, requestId }: MessageActionsProps) { const [copied, setCopied] = useState(false) + const [copiedRequestId, setCopiedRequestId] = useState(false) const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null) const [feedbackText, setFeedbackText] = useState('') const resetTimeoutRef = useRef( +@@ -1081,7 +1084,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template }} > {template.creator.details.about} -
(null) + const requestIdTimeoutRef = useRef (null) const submitFeedback = useSubmitCopilotFeedback() useEffect(() => { @@ -60,6 +64,9 @@ export function MessageActions({ content, chatId, userQuery }: MessageActionsPro if (resetTimeoutRef.current !== null) { window.clearTimeout(resetTimeoutRef.current) } + if (requestIdTimeoutRef.current !== null) { + window.clearTimeout(requestIdTimeoutRef.current) + } } }, []) @@ -79,6 +86,20 @@ export function MessageActions({ content, chatId, userQuery }: MessageActionsPro } }, [content]) + const copyRequestId = useCallback(async () => { + if (!requestId) return + try { + await navigator.clipboard.writeText(requestId) + setCopiedRequestId(true) + if (requestIdTimeoutRef.current !== null) { + window.clearTimeout(requestIdTimeoutRef.current) + } + requestIdTimeoutRef.current = window.setTimeout(() => setCopiedRequestId(false), 1500) + } catch { + /* clipboard unavailable */ + } + }, [requestId]) + const handleFeedbackClick = useCallback( (type: 'up' | 'down') => { if (chatId && userQuery) { @@ -144,6 +165,21 @@ export function MessageActions({ content, chatId, userQuery }: MessageActionsPro > + {requestId && ( + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx index a450bd374da..cbed424d13b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx @@ -17,7 +17,7 @@ export function FileViewer() { return null } - const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace` + const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${file.size}` return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index f62caa1f51c..733e80c6993 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -1,9 +1,33 @@ 'use client' -import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { + memo, + type ReactElement, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import Editor from 'react-simple-code-editor' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-sql' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-yaml' import { createLogger } from '@sim/logger' import { ZoomIn, ZoomOut } from 'lucide-react' -import { Skeleton } from '@/components/emcn' +import { + CODE_LINE_HEIGHT_PX, + Code as CodeEditor, + calculateGutterWidth, + getCodeEditorProps, + highlight, + languages, + Skeleton, +} from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -14,7 +38,6 @@ import { useWorkspaceFileContent, } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' -import { useStreamingText } from '@/hooks/use-streaming-text' import { DataTable } from './data-table' import { PreviewPanel, resolvePreviewType } from './preview-panel' @@ -57,7 +80,7 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([ ...SUPPORTED_CODE_EXTENSIONS, ]) -const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf']) +const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs']) const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) @@ -65,11 +88,13 @@ const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/x-pptxgenjs', ]) const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) const DOCX_PREVIEWABLE_MIME_TYPES = new Set([ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/x-docxjs', ]) const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx']) @@ -87,12 +112,65 @@ type FileCategory = | 'xlsx-previewable' | 'unsupported' +type CodeEditorLanguage = + | 'javascript' + | 'json' + | 'python' + | 'typescript' + | 'bash' + | 'css' + | 'markup' + | 'sql' + | 'yaml' + +const CODE_EDITOR_LANGUAGE_BY_EXTENSION: Partial> = { + js: 'javascript', + jsx: 'javascript', + ts: 'typescript', + tsx: 'typescript', + py: 'python', + json: 'json', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + fish: 'bash', + css: 'css', + scss: 'css', + less: 'css', + html: 'markup', + htm: 'markup', + xml: 'markup', + svg: 'markup', + sql: 'sql', + yaml: 'yaml', + yml: 'yaml', +} + +const CODE_EDITOR_LANGUAGE_BY_MIME: Partial > = { + 'text/javascript': 'javascript', + 'application/javascript': 'javascript', + 'text/typescript': 'typescript', + 'application/typescript': 'typescript', + 'text/x-python': 'python', + 'application/json': 'json', + 'text/x-shellscript': 'bash', + 'text/css': 'css', + 'text/html': 'markup', + 'text/xml': 'markup', + 'application/xml': 'markup', + 'image/svg+xml': 'markup', + 'text/x-sql': 'sql', + 'application/x-yaml': 'yaml', +} + +const CODE_EDITOR_LINE_HEIGHT_PX = CODE_LINE_HEIGHT_PX + function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' - if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable' + if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable' const ext = getFileExtension(filename) @@ -100,8 +178,8 @@ function resolveFileCategory(mimeType: string | null, filename: string): FileCat if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable' if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' - if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable' + if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable' return 'unsupported' @@ -116,6 +194,7 @@ export function isPreviewable(file: { type: string; name: string }): boolean { } export type PreviewMode = 'editor' | 'split' | 'preview' +type StreamingMode = 'append' | 'replace' interface FileViewerProps { file: WorkspaceFileRecord @@ -128,6 +207,286 @@ interface FileViewerProps { onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise ) | null> streamingContent?: string + streamingMode?: StreamingMode + disableStreamingAutoScroll?: boolean + useCodeRendererForCodeFiles?: boolean + previewContextKey?: string +} + +function isCodeFile(file: { type: string; name: string }): boolean { + const ext = getFileExtension(file.name) + return ( + SUPPORTED_CODE_EXTENSIONS.includes(ext as (typeof SUPPORTED_CODE_EXTENSIONS)[number]) || + ext === 'html' || + ext === 'htm' || + ext === 'xml' || + ext === 'svg' + ) +} + +function resolveCodeEditorLanguage(file: { type: string; name: string }): CodeEditorLanguage { + const ext = getFileExtension(file.name) + return ( + CODE_EDITOR_LANGUAGE_BY_EXTENSION[ext] ?? + CODE_EDITOR_LANGUAGE_BY_MIME[file.type] ?? + (ext === 'json' ? 'json' : 'javascript') + ) +} + +function areNumberArraysEqual(a: number[], b: number[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let index = 0; index < a.length; index++) { + if (a[index] !== b[index]) { + return false + } + } + return true +} + +type TextEditorContentPhase = 'uninitialized' | 'ready' | 'streaming' | 'reconciling' + +interface TextEditorContentState { + phase: TextEditorContentPhase + content: string + savedContent: string + lastStreamedContent: string | null +} + +interface SyncTextEditorContentStateOptions { + canReconcileToFetchedContent: boolean + fetchedContent?: string + streamingContent?: string + streamingMode: StreamingMode +} + +type TextEditorContentAction = + | ({ type: 'sync-external' } & SyncTextEditorContentStateOptions) + | { type: 'edit'; content: string } + | { type: 'save-success'; content: string } + +const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = { + phase: 'uninitialized', + content: '', + savedContent: '', + lastStreamedContent: null, +} + +function resolveStreamingEditorContent( + fetchedContent: string | undefined, + streamingContent: string, + streamingMode: StreamingMode +): string { + if (streamingMode === 'replace' || fetchedContent === undefined) { + return streamingContent + } + + if ( + fetchedContent.endsWith(streamingContent) || + fetchedContent.endsWith(`\n${streamingContent}`) + ) { + return fetchedContent + } + + return `${fetchedContent}\n${streamingContent}` +} + +function finalizeTextEditorContentState( + state: TextEditorContentState, + nextContent: string +): TextEditorContentState { + if ( + state.phase === 'ready' && + state.content === nextContent && + state.savedContent === nextContent && + state.lastStreamedContent === null + ) { + return state + } + + return { + phase: 'ready', + content: nextContent, + savedContent: nextContent, + lastStreamedContent: null, + } +} + +function moveTextEditorContentStateToStreaming( + state: TextEditorContentState, + nextContent: string +): TextEditorContentState { + if ( + state.phase === 'streaming' && + state.content === nextContent && + state.lastStreamedContent === nextContent + ) { + return state + } + + return { + ...state, + phase: 'streaming', + content: nextContent, + lastStreamedContent: nextContent, + } +} + +function moveTextEditorContentStateToReconcile( + state: TextEditorContentState +): TextEditorContentState { + if (state.phase === 'reconciling') { + return state + } + + return { + ...state, + phase: 'reconciling', + } +} + +function syncTextEditorContentState( + state: TextEditorContentState, + options: SyncTextEditorContentStateOptions +): TextEditorContentState { + const { canReconcileToFetchedContent, fetchedContent, streamingContent, streamingMode } = options + + if (streamingContent !== undefined) { + const nextContent = resolveStreamingEditorContent( + fetchedContent, + streamingContent, + streamingMode + ) + const fetchedMatchesNextContent = fetchedContent !== undefined && fetchedContent === nextContent + const fetchedMatchesLastStreamedContent = + fetchedContent !== undefined && + state.lastStreamedContent !== null && + fetchedContent === state.lastStreamedContent + const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent + + if ( + (state.phase === 'streaming' || state.phase === 'reconciling') && + (hasFetchedAdvanced || fetchedMatchesLastStreamedContent || fetchedMatchesNextContent) + ) { + return finalizeTextEditorContentState(state, fetchedContent) + } + + if ( + state.phase === 'ready' && + state.content === state.savedContent && + fetchedMatchesNextContent && + fetchedContent !== undefined + ) { + return finalizeTextEditorContentState(state, fetchedContent) + } + + return moveTextEditorContentStateToStreaming(state, nextContent) + } + + if (state.phase === 'streaming' || state.phase === 'reconciling') { + if (!canReconcileToFetchedContent) { + return finalizeTextEditorContentState(state, state.content) + } + + if (fetchedContent !== undefined) { + const hasFetchedAdvanced = fetchedContent !== state.savedContent + const fetchedMatchesLastStreamedContent = + state.lastStreamedContent !== null && fetchedContent === state.lastStreamedContent + + if (hasFetchedAdvanced || fetchedMatchesLastStreamedContent) { + return finalizeTextEditorContentState(state, fetchedContent) + } + } + + return moveTextEditorContentStateToReconcile(state) + } + + if (fetchedContent === undefined) { + return state + } + + if (state.phase === 'uninitialized') { + return finalizeTextEditorContentState(state, fetchedContent) + } + + if (fetchedContent === state.savedContent) { + return state + } + + if (state.content === state.savedContent) { + return finalizeTextEditorContentState(state, fetchedContent) + } + + return state +} + +function textEditorContentReducer( + state: TextEditorContentState, + action: TextEditorContentAction +): TextEditorContentState { + switch (action.type) { + case 'sync-external': + return syncTextEditorContentState(state, action) + case 'edit': + if (state.phase !== 'ready' || action.content === state.content) { + return state + } + return { + ...state, + content: action.content, + } + case 'save-success': + if ( + state.phase === 'ready' && + state.content === action.content && + state.savedContent === action.content && + state.lastStreamedContent === null + ) { + return state + } + return { + ...state, + phase: 'ready', + content: action.content, + savedContent: action.content, + lastStreamedContent: null, + } + default: + return state + } +} + +function useTextEditorContentState(options: SyncTextEditorContentStateOptions) { + const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE) + + useEffect(() => { + dispatch({ + type: 'sync-external', + ...options, + }) + }, [ + options.canReconcileToFetchedContent, + options.fetchedContent, + options.streamingContent, + options.streamingMode, + ]) + + const setDraftContent = useCallback((content: string) => { + dispatch({ type: 'edit', content }) + }, []) + + const markSavedContent = useCallback((content: string) => { + dispatch({ type: 'save-success', content }) + }, []) + + return { + content: state.content, + savedContent: state.savedContent, + isInitialized: state.phase !== 'uninitialized', + isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling', + setDraftContent, + markSavedContent, + } } export function FileViewer({ @@ -141,6 +500,10 @@ export function FileViewer({ onSaveStatusChange, saveRef, streamingContent, + streamingMode, + disableStreamingAutoScroll = false, + useCodeRendererForCodeFiles = false, + previewContextKey, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -149,31 +512,37 @@ export function FileViewer({ ) } if (category === 'iframe-previewable') { - return + return ( + + ) } if (category === 'image-previewable') { - return + return } - if (category === 'pptx-previewable') { - return + if (category === 'docx-previewable') { + return } - if (category === 'docx-previewable') { - return + if (category === 'pptx-previewable') { + return } if (category === 'xlsx-previewable') { @@ -193,6 +562,10 @@ interface TextEditorProps { onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void saveRef?: React.MutableRefObject<(() => Promise ) | null> streamingContent?: string + streamingMode?: StreamingMode + disableStreamingAutoScroll: boolean + useCodeRendererForCodeFiles?: boolean + previewContextKey?: string } function TextEditor({ @@ -205,103 +578,125 @@ function TextEditor({ onSaveStatusChange, saveRef, streamingContent, + streamingMode = 'append', + disableStreamingAutoScroll, + useCodeRendererForCodeFiles = false, + previewContextKey, }: TextEditorProps) { - const initializedRef = useRef(false) - const contentRef = useRef('') const textareaRef = useRef (null) const containerRef = useRef (null) + const codeEditorRef = useRef (null) + const codeScrollRef = useRef (null) + const hasAutoFocusedRef = useRef(false) const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT) const [isResizing, setIsResizing] = useState(false) + const [visualLineHeights, setVisualLineHeights] = useState ([]) + const [activeLineNumber, setActiveLineNumber] = useState(1) const { data: fetchedContent, isLoading, error, - dataUpdatedAt, - } = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs') + } = useWorkspaceFileContent( + workspaceId, + file.id, + file.key, + file.type === 'text/x-pptxgenjs' || + file.type === 'text/x-docxjs' || + file.type === 'text/x-pdflibjs' + ) const updateContent = useUpdateWorkspaceFileContent() const updateContentRef = useRef(updateContent) updateContentRef.current = updateContent - const [content, setContent] = useState('') - const [savedContent, setSavedContent] = useState('') - const savedContentRef = useRef('') + const shouldUseCodeRenderer = useCodeRendererForCodeFiles && isCodeFile(file) + const codeLanguage = useMemo(() => resolveCodeEditorLanguage(file), [file.name, file.type]) + const onDirtyChangeRef = useRef(onDirtyChange) + const onSaveStatusChangeRef = useRef(onSaveStatusChange) + onDirtyChangeRef.current = onDirtyChange + onSaveStatusChangeRef.current = onSaveStatusChange + + const { + content, + savedContent, + isInitialized, + isStreamInteractionLocked, + setDraftContent, + markSavedContent, + } = useTextEditorContentState({ + canReconcileToFetchedContent: file.key.length > 0, + fetchedContent, + streamingContent, + streamingMode, + }) useEffect(() => { - if (streamingContent !== undefined) { - setContent(streamingContent) - contentRef.current = streamingContent - initializedRef.current = true + if (!autoFocus || !isInitialized || hasAutoFocusedRef.current) { return } - if (fetchedContent === undefined) return - - if (!initializedRef.current) { - setContent(fetchedContent) - setSavedContent(fetchedContent) - savedContentRef.current = fetchedContent - contentRef.current = fetchedContent - initializedRef.current = true - - if (autoFocus) { - requestAnimationFrame(() => textareaRef.current?.focus()) + hasAutoFocusedRef.current = true + requestAnimationFrame(() => { + const editorTextarea = codeEditorRef.current?.querySelector('textarea') + if (editorTextarea instanceof HTMLTextAreaElement) { + editorTextarea.focus() + return } - return - } - - if (fetchedContent === savedContentRef.current) return - const isClean = contentRef.current === savedContentRef.current - if (isClean) { - setContent(fetchedContent) - setSavedContent(fetchedContent) - savedContentRef.current = fetchedContent - contentRef.current = fetchedContent - } - }, [streamingContent, fetchedContent, dataUpdatedAt, autoFocus]) + textareaRef.current?.focus() + }) + }, [autoFocus, isInitialized]) - const handleContentChange = useCallback((value: string) => { - setContent(value) - contentRef.current = value - }, []) + const handleContentChange = useCallback( + (value: string) => { + if (value === content) { + return + } + setDraftContent(value) + }, + [content, setDraftContent] + ) const onSave = useCallback(async () => { - const currentContent = contentRef.current - if (currentContent === savedContentRef.current) return + if (content === savedContent) return await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, - content: currentContent, + content, }) - setSavedContent(currentContent) - savedContentRef.current = currentContent - }, [workspaceId, file.id]) + markSavedContent(content) + }, [content, file.id, markSavedContent, savedContent, workspaceId]) const { saveStatus, saveImmediately, isDirty } = useAutosave({ content, savedContent, onSave, - enabled: canEdit && initializedRef.current, + enabled: canEdit && isInitialized && !isStreamInteractionLocked, }) useEffect(() => { - onDirtyChange?.(isDirty) - }, [isDirty, onDirtyChange]) + onDirtyChangeRef.current?.(isDirty) + }, [isDirty]) useEffect(() => { - onSaveStatusChange?.(saveStatus) - }, [saveStatus, onSaveStatusChange]) + onSaveStatusChangeRef.current?.(saveStatus) + }, [saveStatus]) - if (saveRef) saveRef.current = saveImmediately - useEffect( - () => () => { - if (saveRef) saveRef.current = null - }, - [saveRef] - ) + useEffect(() => { + if (!saveRef) { + return + } + + saveRef.current = saveImmediately + + return () => { + if (saveRef.current === saveImmediately) { + saveRef.current = null + } + } + }, [saveImmediately, saveRef]) useEffect(() => { if (!isResizing) return @@ -331,28 +726,186 @@ function TextEditor({ const handleCheckboxToggle = useCallback( (checkboxIndex: number, checked: boolean) => { - const toggled = toggleMarkdownCheckbox(contentRef.current, checkboxIndex, checked) - if (toggled !== contentRef.current) { + const toggled = toggleMarkdownCheckbox(content, checkboxIndex, checked) + if (toggled !== content) { handleContentChange(toggled) } }, - [handleContentChange] + [content, handleContentChange] ) - const isStreaming = streamingContent !== undefined - const revealedContent = useStreamingText(content, isStreaming) + const isStreaming = isStreamInteractionLocked + const isEditorReadOnly = isStreamInteractionLocked || !canEdit + const renderedContent = content + const gutterWidthPx = useMemo(() => { + const lineCount = renderedContent.split('\n').length + return calculateGutterWidth(lineCount) + }, [renderedContent]) + const sharedCodeEditorProps = useMemo( + () => + getCodeEditorProps({ + disabled: isEditorReadOnly, + isStreaming: isStreaming, + }), + [isEditorReadOnly, isStreaming] + ) + const highlightCode = useMemo(() => { + return (value: string) => { + const grammar = languages[codeLanguage] || languages.javascript + return highlight(value, grammar, codeLanguage) + } + }, [codeLanguage]) + const handleCodeContentChange = useCallback( + (value: string) => { + if (isEditorReadOnly) return + handleContentChange(value) + }, + [handleContentChange, isEditorReadOnly] + ) const textareaStuckRef = useRef(true) + useEffect(() => { + if (!shouldUseCodeRenderer) return + const textarea = codeEditorRef.current?.querySelector('textarea') + if (!(textarea instanceof HTMLTextAreaElement)) return + + const updateActiveLineNumber = () => { + const pos = textarea.selectionStart + const textBeforeCursor = renderedContent.substring(0, pos) + const nextActiveLineNumber = textBeforeCursor.split('\n').length + setActiveLineNumber((currentLineNumber) => + currentLineNumber === nextActiveLineNumber ? currentLineNumber : nextActiveLineNumber + ) + } + + updateActiveLineNumber() + textarea.addEventListener('click', updateActiveLineNumber) + textarea.addEventListener('keyup', updateActiveLineNumber) + textarea.addEventListener('focus', updateActiveLineNumber) + + return () => { + textarea.removeEventListener('click', updateActiveLineNumber) + textarea.removeEventListener('keyup', updateActiveLineNumber) + textarea.removeEventListener('focus', updateActiveLineNumber) + } + }, [renderedContent, shouldUseCodeRenderer]) + + useEffect(() => { + if (!shouldUseCodeRenderer || !codeEditorRef.current) return + + const calculateVisualLines = () => { + const preElement = codeEditorRef.current?.querySelector('pre') + if (!(preElement instanceof HTMLElement)) return + + const lines = renderedContent.split('\n') + const newVisualLineHeights: number[] = [] + + const tempContainer = document.createElement('div') + tempContainer.style.cssText = ` + position: absolute; + visibility: hidden; + height: auto; + width: ${preElement.clientWidth}px; + font-family: ${window.getComputedStyle(preElement).fontFamily}; + font-size: ${window.getComputedStyle(preElement).fontSize}; + line-height: ${CODE_EDITOR_LINE_HEIGHT_PX}px; + padding: 8px; + white-space: pre-wrap; + word-break: break-word; + box-sizing: border-box; + ` + document.body.appendChild(tempContainer) + + lines.forEach((line) => { + const lineDiv = document.createElement('div') + lineDiv.textContent = line || ' ' + tempContainer.appendChild(lineDiv) + const actualHeight = lineDiv.getBoundingClientRect().height + const lineUnits = Math.max(1, Math.ceil(actualHeight / CODE_EDITOR_LINE_HEIGHT_PX)) + newVisualLineHeights.push(lineUnits) + tempContainer.removeChild(lineDiv) + }) + + document.body.removeChild(tempContainer) + setVisualLineHeights((currentVisualLineHeights) => + areNumberArraysEqual(currentVisualLineHeights, newVisualLineHeights) + ? currentVisualLineHeights + : newVisualLineHeights + ) + } + + const timeoutId = setTimeout(calculateVisualLines, 50) + const resizeObserver = new ResizeObserver(calculateVisualLines) + resizeObserver.observe(codeEditorRef.current) + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [renderedContent, shouldUseCodeRenderer]) + + const renderCodeLineNumbers = useCallback((): ReactElement[] => { + const numbers: ReactElement[] = [] + let lineNumber = 1 + + visualLineHeights.forEach((height) => { + const isActive = lineNumber === activeLineNumber + numbers.push( + + {lineNumber} ++ ) + + for (let i = 1; i < height; i++) { + numbers.push( ++ {lineNumber} ++ ) + } + + lineNumber++ + }) + + if (numbers.length === 0) { + numbers.push( ++ 1 ++ ) + } + + return numbers + }, [activeLineNumber, visualLineHeights]) + useEffect(() => { if (!isStreaming) return + if (disableStreamingAutoScroll) { + textareaStuckRef.current = false + return + } textareaStuckRef.current = true - const el = textareaRef.current + const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null if (!el) return - const onWheel = (e: WheelEvent) => { - if (e.deltaY < 0) textareaStuckRef.current = false + const onWheel = (e: Event) => { + if ((e as WheelEvent).deltaY < 0) textareaStuckRef.current = false } const onScroll = () => { @@ -367,14 +920,20 @@ function TextEditor({ el.removeEventListener('wheel', onWheel) el.removeEventListener('scroll', onScroll) } - }, [isStreaming]) + }, [disableStreamingAutoScroll, isStreaming, shouldUseCodeRenderer]) useEffect(() => { - if (!isStreaming || !textareaStuckRef.current) return - const el = textareaRef.current + if (!isStreaming || !textareaStuckRef.current || disableStreamingAutoScroll) return + const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null if (!el) return el.scrollTop = el.scrollHeight - }, [isStreaming, revealedContent]) + }, [disableStreamingAutoScroll, isStreaming, renderedContent, shouldUseCodeRenderer]) + + const previewType = resolvePreviewType(file.type, file.name) + const isIframeRendered = previewType === 'html' || previewType === 'svg' + const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode + const showEditor = effectiveMode !== 'preview' + const showPreviewPane = effectiveMode !== 'editor' if (streamingContent === undefined) { if (isLoading) return DOCUMENT_SKELETON @@ -388,29 +947,52 @@ function TextEditor({ } } - const previewType = resolvePreviewType(file.type, file.name) - const isIframeRendered = previewType === 'html' || previewType === 'svg' - const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode - const showEditor = effectiveMode !== 'preview' - const showPreviewPane = effectiveMode !== 'editor' - return (- {showEditor && ( -