diff --git a/apps/web/src/actions/documents/getContentByPath.ts b/apps/web/src/actions/documents/getContentByPath.ts
new file mode 100644
index 000000000..49d1be1b3
--- /dev/null
+++ b/apps/web/src/actions/documents/getContentByPath.ts
@@ -0,0 +1,23 @@
+'use server'
+
+import { getDocumentByPath } from '$/app/(private)/_data-access'
+import { z } from 'zod'
+
+import { withProject } from '../procedures'
+
+export const getDocumentContentByPathAction = withProject
+ .createServerAction()
+ .input(
+ z.object({
+ commitId: z.number(),
+ path: z.string(),
+ }),
+ { type: 'json' },
+ )
+ .handler(async ({ input }) => {
+ const document = await getDocumentByPath({
+ commitId: input.commitId,
+ path: input.path,
+ })
+ return document.content
+ })
diff --git a/apps/web/src/actions/documents/updateContent.ts b/apps/web/src/actions/documents/updateContent.ts
new file mode 100644
index 000000000..eaf97f8fc
--- /dev/null
+++ b/apps/web/src/actions/documents/updateContent.ts
@@ -0,0 +1,25 @@
+'use server'
+
+import { updateDocument } from '@latitude-data/core'
+import { z } from 'zod'
+
+import { withProject } from '../procedures'
+
+export const updateDocumentContentAction = withProject
+ .createServerAction()
+ .input(
+ z.object({
+ documentUuid: z.string(),
+ commitId: z.number(),
+ content: z.string(),
+ }),
+ { type: 'json' },
+ )
+ .handler(async ({ input }) => {
+ const result = await updateDocument({
+ commitId: input.commitId,
+ documentUuid: input.documentUuid,
+ content: input.content,
+ })
+ return result.unwrap()
+ })
diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts
index b50902378..8523bb865 100644
--- a/apps/web/src/app/(private)/_data-access/index.ts
+++ b/apps/web/src/app/(private)/_data-access/index.ts
@@ -2,8 +2,10 @@ import { cache } from 'react'
import {
getDocumentAtCommit,
+ NotFoundError,
findCommitByUuid as originalfindCommit,
findProject as originalFindProject,
+ getDocumentsAtCommit as originalGetDocumentsAtCommit,
getFirstProject as originalGetFirstProject,
type FindCommitByUuidProps,
type FindProjectProps,
@@ -45,3 +47,16 @@ export const getDocumentByUuid = cache(
return document
},
)
+
+export const getDocumentByPath = cache(
+ async ({ commitId, path }: { commitId: number; path: string }) => {
+ const documents = (
+ await originalGetDocumentsAtCommit({ commitId })
+ ).unwrap()
+ const document = documents.find((d) => d.path === path)
+ if (!document) {
+ throw new NotFoundError('Document not found')
+ }
+ return document
+ },
+)
diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts
index 229e7da5d..f09e866cf 100644
--- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts
+++ b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts
@@ -31,7 +31,9 @@ describe('GET documentVersion', () => {
)
expect(response.status).toBe(200)
- expect((await response.json()).id).toEqual(doc.id)
+ const responseDoc = await response.json()
+ expect(responseDoc.documentUuid).toEqual(doc.documentUuid)
+ expect(responseDoc.commitId).toEqual(doc.commitId)
})
test('returns the document in main branch if commitUuid is HEAD', async (ctx) => {
@@ -57,7 +59,9 @@ describe('GET documentVersion', () => {
)
expect(response.status).toBe(200)
- expect((await response.json()).id).toEqual(doc.id)
+ const responseDoc = await response.json()
+ expect(responseDoc.documentUuid).toEqual(doc.documentUuid)
+ expect(responseDoc.commitId).toEqual(doc.commitId)
})
test('returns 404 if document is not found', async (ctx) => {
@@ -103,6 +107,8 @@ describe('GET documentVersion', () => {
)
expect(response.status).toBe(200)
- expect((await response.json()).id).toEqual(doc.id)
+ const responseDoc = await response.json()
+ expect(responseDoc.documentUuid).toEqual(doc.documentUuid)
+ expect(responseDoc.commitId).toEqual(doc.commitId)
})
})
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx
new file mode 100644
index 000000000..bfb82eb4c
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { Suspense, useCallback, useRef } from 'react'
+
+import { Commit, DocumentVersion } from '@latitude-data/core'
+import { DocumentEditor, useToast } from '@latitude-data/web-ui'
+import { getDocumentContentByPathAction } from '$/actions/documents/getContentByPath'
+import { updateDocumentContentAction } from '$/actions/documents/updateContent'
+import { useServerAction } from 'zsa-react'
+
+export default function ClientDocumentEditor({
+ commit,
+ document,
+}: {
+ commit: Commit
+ document: DocumentVersion
+}) {
+ const updateDocumentAction = useServerAction(updateDocumentContentAction)
+ const readDocumentContentAction = useServerAction(
+ getDocumentContentByPathAction,
+ )
+ const { toast } = useToast()
+
+ const documentsByPathRef = useRef<{ [path: string]: string | undefined }>({})
+
+ const readDocument = useCallback(
+ async (path: string) => {
+ const documentsByPath = documentsByPathRef.current
+ if (!(path in documentsByPath)) {
+ const [content, error] = await readDocumentContentAction.execute({
+ projectId: commit.projectId,
+ commitId: commit.id,
+ path,
+ })
+ documentsByPathRef.current = {
+ ...documentsByPath,
+ [path]: error ? undefined : content,
+ }
+ }
+
+ const documentContent = documentsByPath[path]
+ if (documentContent === undefined) {
+ throw new Error('Document not found')
+ }
+
+ return documentContent
+ },
+ [readDocumentContentAction.status, commit.id],
+ )
+
+ const saveDocumentContent = useCallback(
+ async (content: string) => {
+ const [_, error] = await updateDocumentAction.execute({
+ projectId: commit.projectId,
+ documentUuid: document.documentUuid,
+ commitId: commit.id,
+ content,
+ })
+
+ if (error) {
+ toast({
+ title: 'Could not save document',
+ description: error.message,
+ variant: 'destructive',
+ })
+ }
+ },
+ [commit, document, updateDocumentAction, toast],
+ )
+
+ return (
+ Loading...}>
+
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx
index 33b91b6ed..0bfbc02c3 100644
--- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx
@@ -27,7 +27,7 @@ export default async function DocumentLayout({
documentUuid={params.documentUuid}
documentPath={document.path}
/>
-
{children}
+ {children}
)
}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx
index 8409009ec..8203cfadf 100644
--- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx
@@ -1,7 +1,19 @@
-export default function DocumentPage({
+import { findCommit, getDocumentByUuid } from '$/app/(private)/_data-access'
+
+import ClientDocumentEditor from './_components/DocumentEditor'
+
+export default async function DocumentPage({
params,
}: {
params: { projectId: string; commitUuid: string; documentUuid: string }
}) {
- return Documents {params.documentUuid}
+ const commit = await findCommit({
+ projectId: Number(params.projectId),
+ uuid: params.commitUuid,
+ })
+ const document = await getDocumentByUuid({
+ documentUuid: params.documentUuid,
+ commitId: commit.id,
+ })
+ return
}
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index 17292ad17..7a61c1f40 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -45,6 +45,7 @@
"react-dom": "18.3.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
+ "use-debounce": "^10.0.1",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx
index 592c06b90..da339a34e 100644
--- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx
+++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/editor.tsx
@@ -21,12 +21,14 @@ export function DocumentTextEditor({
value,
metadata,
onChange,
+ readOnlyMessage,
}: DocumentTextEditorProps) {
+ const [defaultValue, _] = useState(value)
const editorRef = useRef(null)
const monacoRef = useRef(null)
const [isEditorMounted, setIsEditorMounted] = useState(false) // to avoid race conditions
- function handleEditorWillMount(monaco: Monaco) {
+ const handleEditorWillMount = useCallback((monaco: Monaco) => {
const style = getComputedStyle(document.body)
monaco.languages.register({ id: 'document' })
@@ -39,16 +41,16 @@ export function DocumentTextEditor({
'editor.background': style.getPropertyValue('--secondary'),
},
})
- }
+ }, [])
- function handleEditorDidMount(
- editor: editor.IStandaloneCodeEditor,
- monaco: Monaco,
- ) {
- editorRef.current = editor
- monacoRef.current = monaco
- setIsEditorMounted(true)
- }
+ const handleEditorDidMount = useCallback(
+ (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
+ editorRef.current = editor
+ monacoRef.current = monaco
+ setIsEditorMounted(true)
+ },
+ [],
+ )
const updateMarkers = useCallback(() => {
if (!metadata) return
@@ -77,28 +79,49 @@ export function DocumentTextEditor({
updateMarkers()
}, [metadata, isEditorMounted])
- function handleValueChange(value: string | undefined) {
- if (value) onChange?.(value)
- }
+ const handleValueChange = useCallback(
+ (value: string | undefined) => {
+ onChange?.(value ?? '')
+ },
+ [onChange],
+ )
return (
-
-
+
)
}
diff --git a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx
index 144165c18..c314233fe 100644
--- a/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx
+++ b/packages/web-ui/src/ds/molecules/DocumentTextEditor/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import React, { lazy } from 'react'
+import React, { lazy, useEffect, useState } from 'react'
import type { ConversationMetadata } from '@latitude-data/compiler'
@@ -10,6 +10,7 @@ export type DocumentTextEditorProps = {
value: string
metadata?: ConversationMetadata
onChange?: (value: string) => void
+ readOnlyMessage?: string
}
const DocumentTextEditor = lazy(() =>
@@ -25,7 +26,15 @@ function EditorWrapper(props: DocumentTextEditorProps) {
// When imported, Monaco automatically tries to use the window object.
// Since this is not available when rendering on the server, we only
// render the fallback component for SSR.
- if (typeof window === 'undefined') return
+ const [isBrowser, setIsBrowser] = useState(false)
+
+ useEffect(() => {
+ setIsBrowser(typeof window !== 'undefined')
+ }, [])
+
+ if (!isBrowser) {
+ return
+ }
return
}
diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts
index dcaf82620..42ca5d80d 100644
--- a/packages/web-ui/src/index.ts
+++ b/packages/web-ui/src/index.ts
@@ -2,9 +2,4 @@ export * from './ds/tokens'
export * from './ds/atoms'
export * from './ds/molecules'
export * from './providers'
-
-export { default as DocumentSidebar } from './sections/Document/Sidebar'
-export type { SidebarDocument } from './sections/Document/Sidebar/Files/useTree'
-
-export { default as DocumentDetailWrapper } from './sections/Document/DetailWrapper'
-export * from './sections/Document/Sidebar/Files'
+export * from './sections'
diff --git a/packages/web-ui/src/sections/Document/Editor/index.tsx b/packages/web-ui/src/sections/Document/Editor/index.tsx
index 0ba88a97a..a7611e299 100644
--- a/packages/web-ui/src/sections/Document/Editor/index.tsx
+++ b/packages/web-ui/src/sections/Document/Editor/index.tsx
@@ -1,6 +1,13 @@
'use client'
-import { ReactNode, Suspense, useEffect, useMemo, useState } from 'react'
+import {
+ ReactNode,
+ Suspense,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
import { ConversationMetadata, readMetadata } from '@latitude-data/compiler'
import { Badge, Input, Text } from '$ui/ds/atoms'
@@ -8,6 +15,8 @@ import {
DocumentTextEditor,
DocumentTextEditorFallback,
} from '$ui/ds/molecules'
+import { useCurrentCommit } from '$ui/providers'
+import { useDebouncedCallback } from 'use-debounce'
function Header({ title, children }: { title: string; children?: ReactNode }) {
return (
@@ -18,26 +27,51 @@ function Header({ title, children }: { title: string; children?: ReactNode }) {
)
}
-export function DocumentEditor({ document }: { document: string }) {
+export default function DocumentEditor({
+ document,
+ saveDocumentContent,
+ readDocument,
+}: {
+ document: string
+ saveDocumentContent: (content: string) => void
+ readDocument?: (uuid: string) => Promise
+}) {
const [value, setValue] = useState(document)
const [metadata, setMetadata] = useState()
+
+ const { commit } = useCurrentCommit()
+
+ const debouncedSave = useDebouncedCallback(saveDocumentContent, 2_000)
+
+ const onChange = useCallback((value: string) => {
+ setValue(value)
+ debouncedSave(value)
+ }, [])
+
useEffect(() => {
- readMetadata({ prompt: value }).then(setMetadata)
- }, [value])
+ readMetadata({
+ prompt: value,
+ referenceFn: readDocument,
+ }).then(setMetadata)
+ }, [value, readDocument])
+
const inputs = useMemo(() => {
if (!metadata) return []
return Array.from(metadata.parameters)
}, [metadata])
return (
-
+
}>
diff --git a/packages/web-ui/src/sections/index.ts b/packages/web-ui/src/sections/index.ts
new file mode 100644
index 000000000..1626a1814
--- /dev/null
+++ b/packages/web-ui/src/sections/index.ts
@@ -0,0 +1,7 @@
+export { default as DocumentSidebar } from './Document/Sidebar'
+export type { SidebarDocument } from './Document/Sidebar/Files/useTree'
+
+export { default as DocumentDetailWrapper } from './Document/DetailWrapper'
+export * from './Document/Sidebar/Files'
+
+export { default as DocumentEditor } from './Document/Editor'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86333ce49..139fa716b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -416,6 +416,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.4)
+ use-debounce:
+ specifier: ^10.0.1
+ version: 10.0.1(react@18.3.0)
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -8775,6 +8778,15 @@ packages:
tslib: 2.6.3
dev: false
+ /use-debounce@10.0.1(react@18.3.0):
+ resolution: {integrity: sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ react: '>=16.8.0 || 19.x'
+ dependencies:
+ react: 18.3.0
+ dev: false
+
/use-sidecar@1.1.2(@types/react@18.3.0)(react@18.3.0):
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'}