diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts index a5e0976d2..b50902378 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -1,11 +1,13 @@ import { cache } from 'react' import { + getDocumentAtCommit, findCommitByUuid as originalfindCommit, findProject as originalFindProject, getFirstProject as originalGetFirstProject, type FindCommitByUuidProps, type FindProjectProps, + type GetDocumentAtCommitProps, } from '@latitude-data/core' export const getFirstProject = cache( @@ -34,3 +36,12 @@ export const findCommit = cache( return commit }, ) + +export const getDocumentByUuid = cache( + async ({ documentUuid, commitId }: GetDocumentAtCommitProps) => { + const result = await getDocumentAtCommit({ documentUuid, commitId }) + const document = result.unwrap() + + return document + }, +) 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 9415dd835..aa01106f1 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 @@ -14,9 +14,10 @@ import { useRouter } from 'next/navigation' export default function ClientFilesTree({ documents, - documentUuid, + documentPath, }: { documents: SidebarDocument[] + documentPath: string | undefined documentUuid: string | undefined }) { const router = useRouter() @@ -35,7 +36,7 @@ export default function ClientFilesTree({ return ( ) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx index 12304681b..be0c5273b 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx @@ -8,8 +8,10 @@ import ClientFilesTree from './ClientFilesTree' export default async function Sidebar({ commit, documentUuid, + documentPath, }: { commit: Commit + documentPath?: string documentUuid?: string }) { const documents = await getDocumentsAtCommit({ commitId: commit.id }) @@ -17,6 +19,7 @@ export default async function Sidebar({ 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 e4afc438a..33b91b6ed 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 @@ -1,7 +1,7 @@ import React from 'react' import { DocumentDetailWrapper } from '@latitude-data/web-ui' -import { findCommit } from '$/app/(private)/_data-access' +import { findCommit, getDocumentByUuid } from '$/app/(private)/_data-access' import Sidebar from '../../_components/Sidebar' @@ -16,9 +16,17 @@ export default async function DocumentLayout({ projectId: Number(params.projectId), uuid: params.commitUuid, }) + const document = await getDocumentByUuid({ + documentUuid: params.documentUuid, + commitId: commit.id, + }) return ( - +
{children}
) diff --git a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts index 1e8730edc..eb9d9ff2b 100644 --- a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts +++ b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts @@ -107,6 +107,34 @@ export async function getDocumentsAtCommit( return Result.ok(totalDocuments) } +export type GetDocumentAtCommitProps = { + commitId: number + documentUuid: string +} +export async function getDocumentAtCommit( + { commitId, documentUuid }: GetDocumentAtCommitProps, + tx = database, +): Promise> { + const documentInCommit = await tx.query.documentVersions.findFirst({ + where: and( + eq(documentVersions.commitId, commitId), + eq(documentVersions.documentUuid, documentUuid), + ), + }) + if (documentInCommit !== undefined) return Result.ok(documentInCommit) + + const documentsAtCommit = await getDocumentsAtCommit({ commitId }, tx) + if (documentsAtCommit.error) return Result.error(documentsAtCommit.error) + + const document = documentsAtCommit.value.find( + (d) => d.documentUuid === documentUuid, + ) + + if (!document) return Result.error(new LatitudeError('Document not found')) + + return Result.ok(document) +} + export async function listCommitChanges( { commitId }: { commitId: number }, tx = database, diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 1ec3874d9..17292ad17 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,7 +45,8 @@ "react-dom": "18.3.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^4.5.4" }, "devDependencies": { "@latitude-data/eslint-config": "workspace:*", 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 3ed027430..c7c0ca800 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx @@ -1,12 +1,12 @@ 'use client' -import { ReactNode, useCallback, useState } from 'react' +import { ReactNode, useCallback, useEffect, useState } from 'react' import { Icons } from '$ui/ds/atoms/Icons' import Text from '$ui/ds/atoms/Text' -import { ReactStateDispatch } from '$ui/lib/commonTypes' import { cn } from '$ui/lib/utils' +import { useOpenPaths } from './useOpenPaths' import { Node, SidebarDocument, useTree } from './useTree' const ICON_CLASS = 'w-6 h-6 text-muted-foreground' @@ -45,20 +45,20 @@ function IndentationBar({ function NodeHeaderWrapper({ open, - node, + selected = false, children, indentation, }: { open: boolean + selected?: boolean children: ReactNode - node: Node indentation: IndentType[] }) { return (
@@ -70,21 +70,23 @@ function NodeHeaderWrapper({ function FolderHeader({ node, open, - onClick, indentation, }: { isLast: boolean node: Node open: boolean - onClick: ReactStateDispatch indentation: IndentType[] }) { + const togglePath = useOpenPaths((state) => state.togglePath) const FolderIcon = open ? Icons.folderOpen : Icons.folderClose const ChevronIcon = open ? Icons.chevronDown : Icons.chevronRight + const onTooglePath = useCallback(() => { + togglePath(node.path) + }, [togglePath, node.path]) return ( - +
onClick(!open)} + onClick={onTooglePath} className='flex flex-row items-center gap-x-1' >
@@ -99,32 +101,40 @@ function FolderHeader({ function FileHeader({ open, + selected, node, indentation, navigateToDocument, }: { open: boolean + selected: boolean node: Node indentation: IndentType[] navigateToDocument: (documentUuid: string) => void }) { const handleClick = useCallback(() => { + if (selected) return + navigateToDocument(node.doc!.documentUuid) - }, [node.doc]) + }, [node.doc!.documentUuid, selected]) return ( - +
{node.name} @@ -135,16 +145,16 @@ function FileHeader({ function NodeHeader({ isLast, + selected, node, open, - onClick, indentation, navigateToDocument, }: { isLast: boolean + selected: boolean node: Node open: boolean - onClick: ReactStateDispatch indentation: IndentType[] navigateToDocument: (documentUuid: string) => void }) { @@ -153,6 +163,7 @@ function NodeHeader({ return ( ) @@ -174,24 +184,31 @@ function NodeHeader({ function FileNode({ isLast = false, node, + currentPath, indentation = [], navigateToDocument, }: { node: Node + currentPath: string | undefined isLast?: boolean indentation?: IndentType[] navigateToDocument: (documentUuid: string) => void }) { - const [open, setOpen] = useState(node.containsSelected) + const [selected, setSelected] = useState(currentPath === node.path) + const openPaths = useOpenPaths((state) => state.openPaths) + const open = node.isRoot || openPaths.includes(node.path) const lastIdx = node.children.length - 1 + useEffect(() => { + setSelected(currentPath === node.path) + }, [currentPath]) return (
@@ -204,6 +221,7 @@ function FileNode({ {node.children.map((node, idx) => (
  • void }) { - const rootNode = useTree({ documents, currentDocumentUuid }) - return + const togglePath = useOpenPaths((state) => state.togglePath) + const rootNode = useTree({ documents }) + + useEffect(() => { + if (currentPath) { + togglePath(currentPath) + } + }, [currentPath, togglePath]) + + return ( + + ) } diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.test.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.test.ts new file mode 100644 index 000000000..fa560f4d5 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { useOpenPaths } from './index' + +describe('useOpenPaths', () => { + it('shout add the paths', async () => { + const { result } = renderHook(() => useOpenPaths((state) => state)) + act(() => { + result.current.togglePath('some-folder/nested-folder/doc1') + }) + + expect(result.current.openPaths).toEqual([ + '', + 'some-folder', + 'some-folder/nested-folder', + 'some-folder/nested-folder/doc1', + ]) + }) + + it('shout remove nested paths', async () => { + const { result } = renderHook(() => useOpenPaths((state) => state)) + act(() => { + result.current.togglePath('some-folder/nested-folder/doc1') + }) + + act(() => { + result.current.togglePath('some-folder/nested-folder') + }) + expect(result.current.openPaths).toEqual(['', 'some-folder']) + }) +}) diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.ts new file mode 100644 index 000000000..3c83bee26 --- /dev/null +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useOpenPaths/index.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand' + +type OpenPathsState = { + openPaths: string[] + togglePath: (path: string) => void +} + +function checkIsPathOrDescendant(basePath: string, path: string) { + if (basePath === '' && path !== '') return true + + return path.startsWith(`${basePath}/`) || path === basePath +} + +export const useOpenPaths = create((set) => ({ + openPaths: [''], + togglePath: (path: string) => { + set((state) => { + const isPathOpen = state.openPaths.includes(path) + if (!isPathOpen) { + const segments = path.split('/') + const newPaths = segments.map((_, idx) => + segments.slice(0, idx + 1).join('/'), + ) + + return { + openPaths: [...state.openPaths, ...newPaths], + } + } else { + const filteredPaths = state.openPaths.filter( + (p) => !checkIsPathOrDescendant(path, p), + ) + + return { + openPaths: filteredPaths, + } + } + }) + }, +})) diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts index d96ca634c..5c35315c8 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.test.ts @@ -13,11 +13,10 @@ function fakeRandomId({ uuid }: { uuid?: string } = {}) { function nodeToJson(node: Node): object { return { - name: node.name, id: node.id, + name: node.name, + path: node.path, depth: node.depth, - containsSelected: node.containsSelected, - selected: node.selected, children: node.children.map(nodeToJson), } } @@ -37,7 +36,6 @@ describe('useTree', () => { const { result } = renderHook(() => useTree({ documents: list, - currentDocumentUuid: '4', generateNodeId: fakeRandomId, }), ) @@ -53,53 +51,46 @@ describe('useTree', () => { const { result } = renderHook(() => useTree({ documents: list, - currentDocumentUuid: '4', generateNodeId: fakeRandomId, }), ) expect(nodeToJson(result.current)).toEqual({ id: FAKE_RANDOM_ID, name: 'root', + path: '', depth: 0, - selected: false, - containsSelected: true, children: [ { id: FAKE_RANDOM_ID, name: 'a_thing', + path: 'a_thing', depth: 1, - selected: false, - containsSelected: false, children: [ { - name: 'other-things', id: FAKE_RANDOM_ID, + name: 'other-things', + path: 'a_thing/other-things', depth: 2, - selected: false, - containsSelected: false, children: [ { id: '3', - selected: false, - containsSelected: false, - depth: 3, name: 'doc3', + path: 'a_thing/other-things/doc3', + depth: 3, children: [], }, ], }, { id: '1', - selected: false, - containsSelected: false, - depth: 2, name: 'doc1', + path: 'a_thing/doc1', + depth: 2, children: [], }, { id: '2', - selected: false, - containsSelected: false, + path: 'a_thing/doc2', depth: 2, name: 'doc2', children: [], @@ -107,18 +98,16 @@ describe('useTree', () => { ], }, { - name: 'b_thing', id: FAKE_RANDOM_ID, + name: 'b_thing', + path: 'b_thing', depth: 1, - selected: false, - containsSelected: true, children: [ { id: '4', - selected: true, - containsSelected: false, - depth: 2, name: 'doc4', + path: 'b_thing/doc4', + depth: 2, children: [], }, ], @@ -126,34 +115,30 @@ describe('useTree', () => { { id: FAKE_RANDOM_ID, name: 'z_thing', + path: 'z_thing', depth: 1, - selected: false, - containsSelected: false, children: [ { id: '5', - selected: false, - containsSelected: false, - depth: 2, name: 'doc5', + path: 'z_thing/doc5', + depth: 2, children: [], }, ], }, { id: '7', - selected: false, - containsSelected: false, - depth: 1, name: 'a_doc_7', + path: 'a_doc_7', + depth: 1, children: [], }, { id: '6', - selected: false, - containsSelected: false, - depth: 1, name: 'b_doc_6', + path: 'b_doc_6', + depth: 1, children: [], }, ], diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts index 700a1a6a4..3d6087061 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts @@ -8,46 +8,36 @@ export type SidebarDocument = { export class Node { public id: string public name: string + public path: string public isRoot: boolean = false public isFile: boolean = false public depth: number = 0 - public containsSelected: boolean = false - public selected: boolean = false public children: Node[] = [] public doc?: SidebarDocument - public parent?: Node constructor({ id, doc, children = [], - selected = false, isRoot = false, + path, name = '', }: { id: string - selected: boolean + path: string doc?: SidebarDocument children?: Node[] isRoot?: boolean name?: string }) { this.id = id + this.path = path this.name = isRoot ? 'root' : name - this.selected = selected this.isRoot = isRoot this.isFile = !!doc this.children = children this.doc = doc } - - recursiveSelectParents() { - this.containsSelected = true - - if (this.parent) { - this.parent.recursiveSelectParents() - } - } } function sortByPathDepth(a: SidebarDocument, b: SidebarDocument) { @@ -81,13 +71,11 @@ function findChildrenIndex(node: Node, children: Node[]) { function buildTree({ root, - currentDocumentUuid, nodeMap, documents, generateNodeId, }: { root: Node - currentDocumentUuid?: string nodeMap: Map documents: SidebarDocument[] generateNodeId: typeof defaultGenerateNodeUuid @@ -103,12 +91,11 @@ function buildTree({ if (!nodeMap.has(path)) { const file = isFile ? doc : undefined const uuid = isFile ? doc.documentUuid : undefined - const selected = isFile && uuid === currentDocumentUuid const node = new Node({ id: generateNodeId({ uuid }), - doc: file, - selected: selected, name: segment, + path, + doc: file, }) nodeMap.set(path, node) @@ -127,12 +114,6 @@ function buildTree({ } else { parent.children.splice(index, 0, node) } - - node.parent = parent - - if (selected) { - node.parent.recursiveSelectParents() - } } }) }) @@ -142,19 +123,17 @@ function buildTree({ export function useTree({ documents, - currentDocumentUuid, generateNodeId = defaultGenerateNodeUuid, }: { documents: SidebarDocument[] - currentDocumentUuid: string | undefined generateNodeId?: typeof defaultGenerateNodeUuid }) { return useMemo(() => { const root = new Node({ id: generateNodeId(), + path: '', children: [], isRoot: true, - selected: false, }) const nodeMap = new Map() nodeMap.set('', root) @@ -162,11 +141,10 @@ export function useTree({ const tree = buildTree({ root, - currentDocumentUuid, nodeMap, documents: sorted, generateNodeId, }) return tree - }, [documents, currentDocumentUuid]) + }, [documents]) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 844493588..86333ce49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,9 @@ importers: zod: specifier: ^3.23.8 version: 3.23.8 + zustand: + specifier: ^4.5.4 + version: 4.5.4(@types/react@18.3.0)(react@18.3.0) devDependencies: '@latitude-data/eslint-config': specifier: workspace:* @@ -6012,10 +6015,6 @@ packages: slash: 4.0.0 dev: true - /globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - dev: true - /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -8530,19 +8529,6 @@ packages: /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - /tsconfck@3.1.1(typescript@5.5.3): - resolution: {integrity: sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - dependencies: - typescript: 5.5.3 - dev: true - /tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: @@ -8805,6 +8791,14 @@ packages: tslib: 2.6.3 dev: false + /use-sync-external-store@1.2.0(react@18.3.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || 19.x + dependencies: + react: 18.3.0 + dev: false + /use-sync-external-store@1.2.2(react@19.0.0-rc-378b305958-20240710): resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} peerDependencies: @@ -8891,22 +8885,6 @@ packages: - terser dev: true - /vite-tsconfig-paths@4.3.2(typescript@5.5.3): - resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} - peerDependencies: - vite: '*' - peerDependenciesMeta: - vite: - optional: true - dependencies: - debug: 4.3.5 - globrex: 0.1.2 - tsconfck: 3.1.1(typescript@5.5.3) - transitivePeerDependencies: - - supports-color - - typescript - dev: true - /vite@5.3.3: resolution: {integrity: sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9361,3 +9339,23 @@ packages: dependencies: zod: 3.23.8 dev: false + + /zustand@4.5.4(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-/BPMyLKJPtFEvVL0E9E9BTUM63MNyhPGlvxk1XjrfWTUlV+BR8jufjsovHzrtR6YNcBEcL7cMHovL1n9xHawEg==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8 || 19.x' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.3.0 + react: 18.3.0 + use-sync-external-store: 1.2.0(react@18.3.0) + dev: false