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/app/providers.tsx b/apps/web/src/app/providers.tsx index 1058909d1..70d65a725 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -6,7 +6,11 @@ import { envClient } from '$/envClient' import posthog from 'posthog-js' import { PostHogProvider } from 'posthog-js/react' -if (typeof window !== 'undefined') { +if ( + typeof window !== 'undefined' && + envClient.NEXT_PUBLIC_POSTHOG_KEY && + envClient.NEXT_PUBLIC_POSTHOG_HOST +) { posthog.init(envClient.NEXT_PUBLIC_POSTHOG_KEY, { api_host: envClient.NEXT_PUBLIC_POSTHOG_HOST, person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well diff --git a/apps/web/src/envClient.ts b/apps/web/src/envClient.ts index 71c97dfd8..a494ea5cf 100644 --- a/apps/web/src/envClient.ts +++ b/apps/web/src/envClient.ts @@ -7,7 +7,7 @@ export const envClient = createEnv({ NEXT_PUBLIC_POSTHOG_HOST: z.string(), }, runtimeEnv: { - NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, - NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY ?? '', + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? '', }, }) 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.ts b/packages/core/src/services/documents/renameDocumentPaths.ts new file mode 100644 index 000000000..8e137a82f --- /dev/null +++ b/packages/core/src/services/documents/renameDocumentPaths.ts @@ -0,0 +1,51 @@ +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')) + } + + 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 = document.path.replace(oldPath, newPath) + const updatedDoc = await updateDocument({ + commit, + document, + path: updatedPath, + }) + 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,