diff --git a/apps/web/src/actions/documents/renamePathsAction.ts b/apps/web/src/actions/documents/renamePathsAction.ts new file mode 100644 index 000000000..bac7572ee --- /dev/null +++ b/apps/web/src/actions/documents/renamePathsAction.ts @@ -0,0 +1,32 @@ +'use server' + +import { CommitsRepository } from '@latitude-data/core/repositories' +import { z } from 'zod' + +import { withProject } from '../procedures' +import { renameDocumentPaths } from '@latitude-data/core/services/documents/renameDocumentPaths' + +export const renameDocumentPathsAction = withProject + .createServerAction() + .input( + z.object({ + commitUuid: z.string(), + oldPath: z.string(), + newPath: z.string(), + }), + { type: 'json' }, + ) + .handler(async ({ input, ctx }) => { + const commitsScope = new CommitsRepository(ctx.project.workspaceId) + const commit = await commitsScope + .getCommitByUuid({ uuid: input.commitUuid, projectId: ctx.project.id }) + .then((r) => r.unwrap()) + + const result = await renameDocumentPaths({ + commit, + oldPath: input.oldPath, + newPath: input.newPath, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx index 9244d8cf0..96e266001 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx @@ -45,7 +45,7 @@ export default function ClientFilesTree({ [selectedSegment, project.id, commit.uuid, isHead], ) - const { createFile, destroyFile, destroyFolder, isDestroying, data } = + const { createFile, destroyFile, destroyFolder, renamePaths, isDestroying, data } = useDocumentVersions( { commitUuid: commit.uuid, projectId: project.id }, { @@ -68,6 +68,7 @@ export default function ClientFilesTree({ navigateToDocument={navigateToDocument} onMergeCommitClick={onMergeCommitClick} createFile={createFile} + renamePaths={renamePaths} destroyFile={destroyFile} destroyFolder={destroyFolder} isDestroying={isDestroying} diff --git a/apps/web/src/stores/documentVersions.ts b/apps/web/src/stores/documentVersions.ts index 3b4902795..bebeac9eb 100644 --- a/apps/web/src/stores/documentVersions.ts +++ b/apps/web/src/stores/documentVersions.ts @@ -14,6 +14,7 @@ import { ROUTES } from '$/services/routes' import { useRouter } from 'next/navigation' import useSWR, { SWRConfiguration } from 'swr' import { useServerAction } from 'zsa-react' +import { renameDocumentPathsAction } from '$/actions/documents/renamePathsAction' export default function useDocumentVersions( { @@ -35,6 +36,9 @@ export default function useDocumentVersions( }, }, ) + const { execute: executeRenamePaths } = useServerAction( + renameDocumentPathsAction, + ) const { execute: executeDestroyDocument, isPending: isDestroyingFile } = useServerAction(destroyDocumentAction) const { execute: executeDestroyFolder, isPending: isDestroyingFolder } = @@ -105,6 +109,28 @@ export default function useDocumentVersions( [executeCreateDocument, mutate, data, commitUuid], ) + const renamePaths = useCallback( + async ({ oldPath, newPath }: { oldPath: string; newPath: string }) => { + if (!projectId) return + + const [_, error] = await executeRenamePaths({ + oldPath, + newPath, + projectId, + commitUuid, + }) + + if (error) { + toast({ + title: 'Error renaming paths', + description: error.formErrors?.[0] || error.message, + variant: 'destructive', + }) + } + }, + [executeRenamePaths, mutate, data, commitUuid], + ) + const destroyFile = useCallback( async (documentUuid: string) => { if (!projectId) return @@ -190,6 +216,7 @@ export default function useDocumentVersions( isLoading: isLoading, error: swrError, createFile, + renamePaths, destroyFile, destroyFolder, updateContent, diff --git a/packages/core/src/services/documents/index.ts b/packages/core/src/services/documents/index.ts index 510f860f5..8df94c316 100644 --- a/packages/core/src/services/documents/index.ts +++ b/packages/core/src/services/documents/index.ts @@ -4,3 +4,4 @@ export * from './destroyDocument' export * from './destroyFolder' export * from './recomputeChanges' export * from './getResolvedContent' +export * from './renameDocumentPaths' diff --git a/packages/core/src/services/documents/renameDocumentPaths.test.ts b/packages/core/src/services/documents/renameDocumentPaths.test.ts new file mode 100644 index 000000000..c3f451fe3 --- /dev/null +++ b/packages/core/src/services/documents/renameDocumentPaths.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { Providers } from '../../constants' +import { BadRequestError } from '../../lib' +import { DocumentVersionsRepository } from '../../repositories' +import * as factories from '../../tests/factories' +import { renameDocumentPaths } from './renameDocumentPaths' + +describe('renameDocumentPaths', () => { + it('renames a single document path', async () => { + const { project, user } = await factories.createProject({ + providers: [{ type: Providers.OpenAI, name: 'openai' }], + documents: { + 'a/b': factories.helpers.createPrompt({ provider: 'openai' }), + 'a/b/c': factories.helpers.createPrompt({ provider: 'openai' }), + }, + }) + const { commit: draft } = await factories.createDraft({ project, user }) + + const result = await renameDocumentPaths({ + commit: draft, + oldPath: 'a/b', // a/b/c should not be affected, since I'm only renaming a file + newPath: 'new/path', + }) + + expect(result.ok).toBeTruthy() + + const docsScope = new DocumentVersionsRepository(project.workspaceId) + const docs = await docsScope + .getDocumentsAtCommit(draft) + .then((r) => r.unwrap()) + const paths = docs.map((d) => d.path).sort() + expect(paths).toEqual(['a/b/c', 'new/path']) + }) + + it('renames a folder path', async () => { + const { project, user } = await factories.createProject({ + providers: [{ type: Providers.OpenAI, name: 'openai' }], + documents: { + 'a/b/c': factories.helpers.createPrompt({ provider: 'openai' }), + 'a/b/c/d': factories.helpers.createPrompt({ provider: 'openai' }), + 'not/affected': factories.helpers.createPrompt({ provider: 'openai' }), + }, + }) + const { commit: draft } = await factories.createDraft({ project, user }) + + const result = await renameDocumentPaths({ + commit: draft, + oldPath: 'a/b/', + newPath: 'newpath/', + }) + + expect(result.ok).toBeTruthy() + + const docsScope = new DocumentVersionsRepository(project.workspaceId) + const docs = await docsScope + .getDocumentsAtCommit(draft) + .then((r) => r.unwrap()) + const paths = docs.map((d) => d.path).sort() + expect(paths).toEqual(['newpath/c', 'newpath/c/d', 'not/affected']) + }) + + it('fails when trying to rename a folder as a document', async () => { + const { project, user } = await factories.createProject({ + providers: [{ type: Providers.OpenAI, name: 'openai' }], + documents: { + 'a/b': factories.helpers.createPrompt({ provider: 'openai' }), + }, + }) + const { commit: draft } = await factories.createDraft({ project, user }) + + const result = await renameDocumentPaths({ + commit: draft, + oldPath: 'a/', + newPath: 'new/path', + }) + + expect(result.error).toBeInstanceOf(BadRequestError) + }) +}) diff --git a/packages/core/src/services/documents/renameDocumentPaths.ts b/packages/core/src/services/documents/renameDocumentPaths.ts new file mode 100644 index 000000000..e42443e94 --- /dev/null +++ b/packages/core/src/services/documents/renameDocumentPaths.ts @@ -0,0 +1,60 @@ +import type { Commit, DocumentVersion } from '../../browser' +import { database } from '../../client' +import { findWorkspaceFromCommit } from '../../data-access' +import { Result, Transaction, TypedResult } from '../../lib' +import { BadRequestError } from '../../lib/errors' +import { DocumentVersionsRepository } from '../../repositories' +import { updateDocument } from './update' + +export async function renameDocumentPaths( + { + commit, + oldPath, + newPath, + }: { + commit: Commit + oldPath: string + newPath: string + }, + db = database, +): Promise> { + return await Transaction.call(async (tx) => { + if (commit.mergedAt !== null) { + return Result.error(new BadRequestError('Cannot modify a merged commit')) + } + + if (oldPath.endsWith('/') !== newPath.endsWith('/')) { + return Result.error(new BadRequestError('Trying to rename a folder as a document or vice versa')) + } + + const workspace = await findWorkspaceFromCommit(commit, tx) + const docsScope = new DocumentVersionsRepository(workspace!.id, tx) + + const currentDocs = await docsScope + .getDocumentsAtCommit(commit) + .then((r) => r.unwrap()) + + const docsToUpdate = currentDocs.filter((d) => + oldPath.endsWith('/') ? d.path.startsWith(oldPath) : d.path === oldPath, + ) + + const updatedDocs = await Promise.all( + docsToUpdate.map(async (document) => { + const updatedPath = newPath + document.path.slice(oldPath.length) + // A simple replace would also replace other instances of oldPath in the path + // For example, relpacing "a" to "b" in "a/name" would result in "b/nbme" + const updatedDoc = await updateDocument( + { + commit, + document, + path: updatedPath, + }, + tx, + ) + return updatedDoc.unwrap() + }), + ) + + return Result.ok(updatedDocs) + }, db) +} diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index a4bb8f0b6..cdc5462bb 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -80,6 +80,8 @@ export const env = createEnv({ SENTRY_DSN: z.string().optional(), SENTRY_ORG: z.string().optional(), SENTRY_PROJECT: z.string().optional(), + NEXT_PUBLIC_POSTHOG_KEY: z.string(), + NEXT_PUBLIC_POSTHOG_HOST: z.string(), }, runtimeEnv: { ...process.env, diff --git a/packages/web-ui/src/ds/atoms/Icons/index.tsx b/packages/web-ui/src/ds/atoms/Icons/index.tsx index 92e06cf7a..be3567374 100644 --- a/packages/web-ui/src/ds/atoms/Icons/index.tsx +++ b/packages/web-ui/src/ds/atoms/Icons/index.tsx @@ -21,6 +21,7 @@ import { LoaderCircle, Lock, Moon, + Pencil, RefreshCcw, SquareDot, SquareMinus, @@ -63,6 +64,7 @@ const Icons = { sun: Sun, eye: Eye, externalLink: ExternalLink, + pencil: Pencil, refresh: RefreshCcw, } diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/DocumentHeader/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/DocumentHeader/index.tsx index 53b29ca1f..22202c7fe 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/DocumentHeader/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/DocumentHeader/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Icon } from '../../../../../ds/atoms' import { MenuOption } from '../../../../../ds/atoms/DropdownMenu' @@ -57,8 +57,22 @@ export default function DocumentHeader({ onNavigateToDocument(node.doc!.documentUuid) }, [node.doc!.documentUuid, selected, node.isPersisted, onNavigateToDocument]) + const [isEditing, setIsEditing] = useState(node.name === " ") const actions = useMemo( () => [ + { + label: 'Rename', + disabled: isMerged, + iconProps: { name: 'pencil' }, + onClick: () => { + if (isMerged) { + onMergeCommitClick() + return + } + + setIsEditing(true) + }, + }, { label: 'Delete file', type: 'destructive', @@ -80,6 +94,8 @@ export default function DocumentHeader({ isFile open={open} name={node.name} + isEditing={isEditing} + setIsEditing={setIsEditing} hasChildren={false} actions={actions} selected={selected} diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/FolderHeader/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/FolderHeader/index.tsx index bb114736e..e6545e6c9 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/FolderHeader/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/FolderHeader/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Icon } from '../../../../../ds/atoms' import { MenuOption } from '../../../../../ds/atoms/DropdownMenu' @@ -72,8 +72,22 @@ export default function FolderHeader({ }, [node.path, togglePath, open, isMerged, onMergeCommitClick, addFolder], ) + const [isEditing, setIsEditing] = useState(node.name === ' ') const actions = useMemo( () => [ + { + label: 'Rename', + disabled: isMerged, + iconProps: { name: 'pencil' }, + onClick: () => { + if (isMerged) { + onMergeCommitClick() + return + } + + setIsEditing(true) + }, + }, { label: 'New folder', disabled: isMerged, @@ -120,6 +134,8 @@ export default function FolderHeader({ return ( 0} onClick={onToggleOpen} onSaveValue={({ path }) => updateFolder({ id: node.id, path })} diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx index 386530b64..96fd331a3 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/index.tsx @@ -46,6 +46,8 @@ type Props = { hasChildren?: boolean isFile?: boolean selected?: boolean + isEditing: boolean + setIsEditing: (isEditing: boolean) => void onClick?: () => void actions?: MenuOption[] icons: ReactNode @@ -60,6 +62,8 @@ function NodeHeaderWrapper({ open, hasChildren = false, isFile = false, + isEditing, + setIsEditing, onSaveValue, onSaveValueAndTab, onLeaveWithoutSave, @@ -72,10 +76,12 @@ function NodeHeaderWrapper({ const [tmpName, setTmpName] = useState(name) const inputRef = useRef(null) const nodeRef = useRef(null) - const { isEditing, error, onInputChange, onInputKeyDown } = useNodeValidator({ + const { error, inputValue, onInputChange, onInputKeyDown } = useNodeValidator({ name, nodeRef, inputRef, + isEditing, + setIsEditing, saveValue: ({ path }) => { setTmpName(path) onSaveValue({ path }) @@ -123,6 +129,7 @@ function NodeHeaderWrapper({ tabIndex={0} ref={inputRef} autoFocus + value={inputValue} onKeyDown={onInputKeyDown} onChange={onInputChange} errors={error ? [error] : undefined} diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/useNodeValidator/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/useNodeValidator/index.ts index fc4d1504b..d4aac2883 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/useNodeValidator/index.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/NodeHeaderWrapper/useNodeValidator/index.ts @@ -45,6 +45,8 @@ export function useNodeValidator({ name, inputRef, nodeRef, + isEditing, + setIsEditing, leaveWithoutSave, saveValue, saveAndAddOther, @@ -52,12 +54,14 @@ export function useNodeValidator({ name: string | undefined inputRef: RefObject nodeRef: RefObject + isEditing: boolean + setIsEditing: (isEditing: boolean) => void saveValue: (args: { path: string }) => Promise | void saveAndAddOther?: (args: { path: string }) => void leaveWithoutSave?: () => void }) { - const [isEditing, setIsEditing] = useState(name === ' ') const [validationError, setError] = useState() + const [inputValue, setInputValue] = useState(name) const onInputChange: ChangeEventHandler = useCallback( (event) => { const value = event.target.value @@ -70,8 +74,9 @@ export function useNodeValidator({ } setError(error) + setInputValue(value) }, - [setError], + [setError, setInputValue], ) const onClickOutside = useCallback(async () => { const val = inputRef.current?.value ?? '' @@ -119,6 +124,7 @@ export function useNodeValidator({ ) return { + inputValue, isEditing, onInputChange, onInputKeyDown, diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/TreeToolbar/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/TreeToolbar/index.tsx index 1530dc0e0..e4d7384dd 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/TreeToolbar/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/TreeToolbar/index.tsx @@ -54,7 +54,9 @@ export function TreeToolbar() { open={false} hasChildren={false} isFile={isFile} - name=' ' + name='' + isEditing={true} + setIsEditing={() => {}} icons={isFile ? : } indentation={[{ isLast: true }]} onSaveValue={async ({ path }) => { diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx index 3621c5f7e..51f0eb7f0 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx @@ -137,12 +137,14 @@ export function FilesTree({ onMergeCommitClick, navigateToDocument, createFile, + renamePaths, destroyFile, destroyFolder, isDestroying, }: { isMerged: boolean createFile: (args: { path: string }) => Promise + renamePaths: (args: { oldPath: string; newPath: string }) => Promise destroyFile: (documentUuid: string) => Promise onMergeCommitClick: () => void destroyFolder: (path: string) => Promise @@ -185,6 +187,7 @@ export function FilesTree({ onCreateFile={async (path) => { createFile({ path }) }} + onRenameFile={renamePaths} onDeleteFile={({ node, documentUuid }) => { setDeletable({ type: DeletableType.File,