@@ -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