diff --git a/README.md b/README.md
index 0c29c83..41d506f 100755
--- a/README.md
+++ b/README.md
@@ -30,16 +30,17 @@ npm i exploration
## Features
-- [x] Zero-recursion, expandable tree
-- [x] Virtualization
-- [x] Create/move/rename/delete
-- [x] Drag and drop
-- [x] Hotkeys
-- [x] Multiselect
-- [x] Traits (e.g. add class names to selections, focused elements, etc.)
-- [x] Filtering/search
-- [x] Strongly typed so you can engineer with confidence
-- [x] Ready for React 18 concurrent mode
+- [x] **Zero-recursion**, expandable tree
+- [x] **Virtualization**, only render what is visible
+- [x] **Create/delete/move/rename** actions
+- [x] **Drag and drop**
+- [x] **Hotkeys**
+- [x] **Multiselect**
+- [x] **Traits**, add class names to selections, focused elements, anything
+- [x] **Filtering/search**
+- [x] **Tree snapshot restoration** for persisting the expanded state of the tree between refreshes
+- [x] **Strongly typed** so you can engineer with confidence
+- [x] **Concurrent mode** safe, ready for React 18
---
@@ -48,9 +49,8 @@ npm i exploration
File explorers in React tend to be both large, slow, and opinionated. They peter out at a
hundred nodes and aren't suitable for building a complex file explorer in the browser. Other
solutions like [Aspen](https://github.com/zikaari/aspen) aimed to solve this problem by
-using typed arrays (which AFAICT don't offer much benefit) and event-driven models. They
-did a pretty job, however, the documentation was sparse and the code was verbose as a result
-of a well-designed API. Additionally, I found the library to be quite buggy. All that said,
+using typed arrays (which don't seem to offer much benefit in performance) and event-driven models.
+They did a pretty job, however, the documentation was sparse and the code was verbose. All that said,
Aspen and Monaco's tree were huge inspirations.
## The solution
@@ -78,6 +78,7 @@ Most importantly - it's easy to use. So check out the recipes below and give it
1. [**How to do perfom an action when a file is selected (opened)**](#)
1. [**How to filter the list of visible files/directories**](#)
1. [**How to write your own file tree plugin**](#)
+1. [**How to restore the state of the file tree from local storage**](#)
---
@@ -122,6 +123,10 @@ type FileTreeConfig = {
* The root node data
*/
root?: Omit, "type">;
+ /**
+ * Restore the tree from a snapshot
+ */
+ restoreFromSnapshot?: FileTreeSnapshot;
};
```
@@ -167,6 +172,15 @@ interface UseVirtualizeResult {
* `true` if the viewport is currently scrolling
*/
isScrolling: boolean;
+ /**
+ * Scroll to the viewport a given position
+ * @param scrollTop - The new scroll position
+ * @param config - Configuration options
+ */
+ scrollTo(
+ scrollTop: number,
+ config: Pick
+ ): void;
/**
* Scroll to a given node by its ID
* @param nodeId - The node ID to scroll to
@@ -551,6 +565,23 @@ A hook for subscribing to changes to the value of an observable.
---
+### useFileTreeSnapshot()
+
+Take a snapshot of the expanded and buried directories of a file tree.
+This snapshot can be used to restore the expanded/collapsed state of the
+file tree when you initially load it.
+
+#### Arguments
+
+| Name | Type | Required? | Description |
+| -------- | ---------------------------------------------------- | --------- | ---------------------------------------------- |
+| fileTree | `FileTree` | Yes | A file tree |
+| callback | `(state: FileTreeSnapshot) => Promise \| void` | Yes | A callback that handles the file tree snapshot |
+
+## [**⇗ Back to top**](#exploration)
+
+---
+
## Low-level API
### FileTree()
@@ -599,22 +630,22 @@ A class for creating a directory node.
#### Arguments
-| Name | Type | Required? | Description |
-| -------- | ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| parent | `Dir` | Yes | The parent node |
-| data | [`FileTreeData`](#filetreedatameta) | Yes | The node data |
-| expanded | `boolean` | No | Whether the node is expanded or not, defaults to `false`. This is an optimistic property, so when it is `true` its descendants may not be fully loaded yet. To get the more accurate representation, use `FileTree#isExpanded`. |
+| Name | Type | Required? | Description |
+| -------- | ------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| parent | `Dir` | Yes | The parent node |
+| data | [`FileTreeData`](#filetreedata) | Yes | The node data |
+| expanded | `boolean` | No | Whether the node is expanded or not, defaults to `false`. This is an optimistic property, so when it is `true` its descendants may not be fully loaded yet. To get the more accurate representation, use `FileTree#isExpanded`. |
#### Properties
-| Name | Type | Description |
-| -------- | ----------------------------------------- | ------------------------------------ |
-| parentId | `number` | The ID of the parent node. |
-| parent | `Dir` | The parent node. |
-| basename | `string` | The basename of the directory. |
-| path | `string` | The full path of the directory. |
-| expanded | `boolean` | `true` if the directory is expanded. |
-| data | [`FileTreeData`](#filetreedatameta) | The node data |
+| Name | Type | Description |
+| -------- | ------------------------------------- | ------------------------------------ |
+| parentId | `number` | The ID of the parent node. |
+| parent | `Dir` | The parent node. |
+| basename | `string` | The basename of the directory. |
+| path | `string` | The full path of the directory. |
+| expanded | `boolean` | `true` if the directory is expanded. |
+| data | [`FileTreeData`](#filetreedata) | The node data |
#### Methods
diff --git a/package.json b/package.json
index 3a42b68..39505bd 100755
--- a/package.json
+++ b/package.json
@@ -36,7 +36,6 @@
"@essentials/request-timeout": "^1.3.0",
"@react-hook/hotkey": "^3.1.0",
"clsx": "^1.1.1",
- "is-relative": "^1.0.0",
"trie-memoize": "^1.2.0",
"use-subscription": "^1.6.0",
"use-sync-external-store": "^1.0.0"
@@ -118,7 +117,7 @@
},
"jest": {
"collectCoverageFrom": [
- "**/src/**/*.{ts,tsx}"
+ "src/**/*.{ts,tsx}"
],
"globals": {
"__DEV__": true
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3432251..d460911 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,7 +25,6 @@ specifiers:
eslint: ^7.32.0
eslint-config-lunde: latest
husky: latest
- is-relative: ^1.0.0
jest: latest
lint-staged: latest
lundle: latest
@@ -44,7 +43,6 @@ dependencies:
'@essentials/request-timeout': 1.3.0
'@react-hook/hotkey': 3.1.0_react@18.0.0
clsx: 1.1.1
- is-relative: 1.0.0
trie-memoize: 1.2.0
use-subscription: 1.6.0_react@18.0.0
use-sync-external-store: 1.0.0_react@18.0.0
@@ -57,9 +55,9 @@ devDependencies:
'@swc-node/core': 1.8.2
'@swc-node/jest': 1.4.3
'@testing-library/jest-dom': 5.16.4
- '@testing-library/react': 13.0.1_react-dom@18.0.0+react@18.0.0
+ '@testing-library/react': 13.1.1_react-dom@18.0.0+react@18.0.0
'@testing-library/react-hooks': 8.0.0_cd4527864787a867d693ee49f6050614
- '@testing-library/user-event': 14.1.0
+ '@testing-library/user-event': 14.1.1
'@types/is-relative': 1.0.0
'@types/jest': 27.4.1
'@types/react': 18.0.0
@@ -71,7 +69,7 @@ devDependencies:
eslint-config-lunde: 0.7.1_a1aa4a46c63093a0ce0f642d68ebaa46
husky: 7.0.4
jest: 27.5.1
- lint-staged: 12.3.7
+ lint-staged: 12.3.8
lundle: 0.4.13
prettier: 2.6.2
react: 18.0.0
@@ -2459,8 +2457,8 @@ packages:
react-test-renderer: 18.0.0_react@18.0.0
dev: true
- /@testing-library/react/13.0.1_react-dom@18.0.0+react@18.0.0:
- resolution: {integrity: sha512-zeHx3PohYYp+4bTJwrixQY8zSBZjWUGwYc7OhD1EpWTHS92RleApLoP72NdwaWxOrM1P1Uezt3XvGf6t2XSWPQ==}
+ /@testing-library/react/13.1.1_react-dom@18.0.0+react@18.0.0:
+ resolution: {integrity: sha512-8mirlAa0OKaUvnqnZF6MdAh2tReYA2KtWVw1PKvaF5EcCZqgK5pl8iF+3uW90JdG5Ua2c2c2E2wtLdaug3dsVg==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.0.0
@@ -2473,8 +2471,8 @@ packages:
react-dom: 18.0.0_react@18.0.0
dev: true
- /@testing-library/user-event/14.1.0:
- resolution: {integrity: sha512-+CGfMXlVM+OwREHDEsfTGsXIMI+rjr3a7YBUSutq7soELht+8kQrM5k46xa/WLfHdtX/wqsDIleL6bi4i+xz0w==}
+ /@testing-library/user-event/14.1.1:
+ resolution: {integrity: sha512-XrjH/iEUqNl9lF2HX9YhPNV7Amntkcnpw0Bo1KkRzowNDcgSN9i0nm4Q8Oi5wupgdfPaJNMAWa61A+voD6Kmwg==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
@@ -5203,13 +5201,6 @@ packages:
has-tostringtag: 1.0.0
dev: true
- /is-relative/1.0.0:
- resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==}
- engines: {node: '>=0.10.0'}
- dependencies:
- is-unc-path: 1.0.0
- dev: false
-
/is-shared-array-buffer/1.0.2:
resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
dependencies:
@@ -5246,13 +5237,6 @@ packages:
resolution: {integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=}
dev: true
- /is-unc-path/1.0.0:
- resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==}
- engines: {node: '>=0.10.0'}
- dependencies:
- unc-path-regex: 0.1.2
- dev: false
-
/is-utf8/0.2.1:
resolution: {integrity: sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=}
dev: true
@@ -5969,8 +5953,8 @@ packages:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: true
- /lint-staged/12.3.7:
- resolution: {integrity: sha512-/S4D726e2GIsDVWIk1XGvheCaDm1SJRQp8efamZFWJxQMVEbOwSysp7xb49Oo73KYCdy97mIWinhlxcoNqIfIQ==}
+ /lint-staged/12.3.8:
+ resolution: {integrity: sha512-0+UpNaqIwKRSGAFOCcpuYNIv/j5QGVC+xUVvmSdxHO+IfIGoHbFLo3XcPmV/LLnsVj5EAncNHVtlITSoY5qWGQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
dependencies:
@@ -7702,11 +7686,6 @@ packages:
which-boxed-primitive: 1.0.2
dev: true
- /unc-path-regex/0.1.2:
- resolution: {integrity: sha1-5z3T17DXxe2G+6xrCufYxqadUPo=}
- engines: {node: '>=0.10.0'}
- dev: false
-
/unicode-canonical-property-names-ecmascript/2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
diff --git a/src/file-tree.test.ts b/src/file-tree.test.ts
index cfff815..d1e1fe5 100644
--- a/src/file-tree.test.ts
+++ b/src/file-tree.test.ts
@@ -356,8 +356,7 @@ describe("createFileTree()", () => {
insert(
createDir({
name: "/.github/issue-templates",
- }),
- 1
+ })
);
});
expect(node.nodes.length).toBe(mockFs["/.github"].length + 2);
@@ -397,8 +396,7 @@ describe("createFileTree()", () => {
insert(
createFile({
name: "/.github/issue-templates",
- }),
- 1
+ })
);
});
@@ -585,6 +583,27 @@ describe("createFileTree()", () => {
tree.dispose();
expect(nodesById[tree.root.id]).toBe(undefined);
});
+
+ it("should restore from a snapshot", async () => {
+ const tree = createFileTree(getNodesFromMockFs, {
+ restoreFromSnapshot: {
+ expandedPaths: ["/.github"],
+ buriedPaths: ["/.husky/hooks"],
+ version: 1,
+ },
+ });
+ await waitForTree(tree);
+ const dir = tree.getById(tree.visibleNodes[0]) as Dir;
+ expect(dir.expanded).toBe(true);
+ expect(dir.nodes).not.toBeUndefined();
+
+ const husky = tree.getById(tree.root.nodes[1]) as Dir;
+ await tree.expand(husky);
+
+ const huskyHooks = tree.getById(husky.nodes[0]) as Dir;
+ expect(huskyHooks.expanded).toBe(true);
+ expect(huskyHooks.nodes).not.toBeUndefined();
+ });
});
describe("file tree actions", () => {
@@ -680,7 +699,7 @@ describe("isFile()", () => {
function waitForTree(tree: Tree) {
// @ts-expect-error: private access
- return Promise.all(tree.pendingLoadChildrenRequests.values());
+ return Promise.all(tree.loadingBranches.values());
}
function getNodesFromMockFs(parent: any, { createFile, createDir }: any) {
diff --git a/src/file-tree.ts b/src/file-tree.ts
index 58efc78..59a4b32 100644
--- a/src/file-tree.ts
+++ b/src/file-tree.ts
@@ -4,6 +4,7 @@ import { Leaf } from "./tree/leaf";
import { nodesById } from "./tree/nodes-by-id";
import type { GetNodes as GetNodesBase } from "./tree/tree";
import { Tree } from "./tree/tree";
+import type { FileTreeSnapshot } from "./types";
/**
* Create a file tree that can be used with the React API.
@@ -17,9 +18,9 @@ export function createFileTree(
getNodes: GetNodes,
config: FileTreeConfig = {}
) {
- const { comparator = defaultComparator, root } = config;
+ const { comparator = defaultComparator, root, restoreFromSnapshot } = config;
- return new FileTree({
+ const tree = new FileTree({
async getNodes(parent) {
const factory: FileTreeFactory = {
createFile(data) {
@@ -27,7 +28,25 @@ export function createFileTree(
},
createDir(data, expanded?: boolean) {
- return new Dir(parent, data, expanded);
+ const path =
+ parent.id === -1
+ ? data.name
+ : pathFx.join(
+ // @ts-expect-error: branch type but is dir
+ parent.path,
+ pathFx.basename(data.name)
+ );
+
+ return new Dir(
+ parent,
+ data,
+ expanded ??
+ !!(
+ restoreFromSnapshot &&
+ (restoreFromSnapshot.expandedPaths.includes(path) ||
+ restoreFromSnapshot.buriedPaths.includes(path))
+ )
+ );
},
};
@@ -36,6 +55,8 @@ export function createFileTree(
comparator,
root: new Dir(null, root ? { ...root } : { name: "/" }),
});
+
+ return tree;
}
export class FileTree extends Tree> {
@@ -45,6 +66,8 @@ export class FileTree extends Tree> {
declare root: Dir;
protected declare treeNodeMap: Map | Dir>;
declare nodesById: FileTreeNode[];
+ protected declare loadingBranches: Map, Promise>;
+
/**
* Get a node by its ID.
*
@@ -232,9 +255,9 @@ export class File extends Leaf> {
/**
* The full path of the file
*/
- get path() {
+ get path(): string {
if (this.parentId > -1) {
- return pathFx.join(this.parent!.data.name, this.basename);
+ return pathFx.join(this.parent!.path, this.basename);
}
return this.data.name;
@@ -261,9 +284,9 @@ export class Dir extends Branch> {
/**
* The full path of the directory
*/
- get path() {
+ get path(): string {
if (this.parentId > -1) {
- return pathFx.join(this.parent!.data.name, this.basename);
+ return pathFx.join(this.parent!.path, this.basename);
}
return this.data.name;
@@ -346,4 +369,8 @@ export type FileTreeConfig = {
* The root node data
*/
root?: Omit, "type">;
+ /**
+ * Restore the tree from a snapshot
+ */
+ restoreFromSnapshot?: FileTreeSnapshot;
};
diff --git a/src/index.ts b/src/index.ts
index 8e5a9ac..31c50d5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,7 @@ export { ObservableMap, ObservableSet } from "./observable-data";
export * as pathFx from "./path-fx";
export { observable } from "./tree/observable";
export type { Observable } from "./tree/observable";
+export type { FileTreeSnapshot, WindowRef } from "./types";
export { useDnd } from "./use-dnd";
export type { DndEvent, DndProps, UseDndPlugin, UseDndConfig } from "./use-dnd";
export { useFilter } from "./use-filter";
@@ -20,6 +21,7 @@ export { useHotkeys } from "./use-hotkeys";
export { useNodePlugins } from "./use-node-plugins";
export type { NodePlugin } from "./use-node-plugins";
export { useObservable } from "./use-observable";
+export { useFileTreeSnapshot } from "./use-file-tree-snapshot";
export { useTraits } from "./use-traits";
export type { TraitsProps, UseTraitsPlugin } from "./use-traits";
export { useRovingFocus } from "./use-roving-focus";
diff --git a/src/path-fx.test.ts b/src/path-fx.test.ts
index 566b772..4994ce8 100644
--- a/src/path-fx.test.ts
+++ b/src/path-fx.test.ts
@@ -125,3 +125,12 @@ describe("normalize()", () => {
expect(pathFx.normalize("/foo/bar/baz/qux/../../../")).toBe("/foo");
});
});
+
+describe("isRelative()", () => {
+ it("should return true if the path is relative", () => {
+ expect(pathFx.isRelative("foo/bar")).toBe(true);
+ expect(pathFx.isRelative("foo")).toBe(true);
+ expect(pathFx.isRelative("/foo")).toBe(false);
+ expect(pathFx.isRelative("/foo/bar")).toBe(false);
+ });
+});
diff --git a/src/path-fx.ts b/src/path-fx.ts
index e4818bf..f021f5f 100644
--- a/src/path-fx.ts
+++ b/src/path-fx.ts
@@ -1,11 +1,16 @@
-import isRelative from "is-relative";
-
const ANY_LEADING_SEP_RE = /^[\\/]+/;
const ANY_TRAILING_SEP_RE = /[\\/]+$/;
const UNIX_JOIN_CHARACTER = "/";
const UNIX_SEP_NEGATE_RE = /[^/]+/g;
-export { default as isRelative } from "is-relative";
+/**
+ * Returns `true` if the path is relative.
+ *
+ * @param path - The path to check
+ */
+export function isRelative(path: string): boolean {
+ return path.charAt(0) !== "/";
+}
/**
* Join all arguments together and normalize the resulting path.
diff --git a/src/test/utils.ts b/src/test/utils.ts
index b44da7c..1fb4946 100644
--- a/src/test/utils.ts
+++ b/src/test/utils.ts
@@ -48,7 +48,7 @@ export const mockFs = {
export function waitForTree(tree: FileTree) {
// @ts-expect-error: private access
- return Promise.all(tree.pendingLoadChildrenRequests.values());
+ return Promise.all(tree.loadingBranches.values());
}
export function getNodesFromMockFs(
diff --git a/src/tree/tree.ts b/src/tree/tree.ts
index 81941ec..f6c458c 100644
--- a/src/tree/tree.ts
+++ b/src/tree/tree.ts
@@ -6,10 +6,7 @@ import { nodesById } from "./nodes-by-id";
import { observable } from "./observable";
export class Tree {
- private pendingLoadChildrenRequests = new Map<
- Branch,
- Promise
- >();
+ protected loadingBranches = new Map, Promise>();
private getNodes: GetNodes;
comparator?: (a: Node, b: Node) => number;
flatView = observable(0);
@@ -256,7 +253,7 @@ export class Tree {
* @param branch - The branch to load nodes for
*/
async loadNodes(branch: Branch): Promise {
- const promise = this.pendingLoadChildrenRequests.get(branch);
+ const promise = this.loadingBranches.get(branch);
if (!promise) {
const promise = (async (): Promise => {
@@ -272,8 +269,8 @@ export class Tree {
}
})();
- promise.finally(() => this.pendingLoadChildrenRequests.delete(branch));
- this.pendingLoadChildrenRequests.set(branch, promise);
+ promise.finally(() => this.loadingBranches.delete(branch));
+ this.loadingBranches.set(branch, promise);
return promise;
}
diff --git a/src/types.ts b/src/types.ts
index 51278ee..2acfda1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -3,3 +3,19 @@ export type WindowRef =
| React.MutableRefObject
| HTMLElement
| null;
+
+export type FileTreeSnapshot = {
+ /**
+ * The expanded paths of the file tree.
+ */
+ expandedPaths: string[];
+ /**
+ * The buried paths of the file tree. That is, directories that are expanded
+ * but not visible.
+ */
+ buriedPaths: string[];
+ /**
+ * The version of the snapshot schema.
+ */
+ version: 1;
+};
diff --git a/src/use-file-tree-snapshot.test.ts b/src/use-file-tree-snapshot.test.ts
new file mode 100644
index 0000000..87cc872
--- /dev/null
+++ b/src/use-file-tree-snapshot.test.ts
@@ -0,0 +1,52 @@
+import { renderHook } from "@testing-library/react-hooks";
+import { useFileTreeSnapshot } from ".";
+import type { Dir } from "./file-tree";
+import { createFileTree } from "./file-tree";
+import { getNodesFromMockFs, waitForTree } from "./test/utils";
+
+describe("useFileTreeSnapshot()", () => {
+ let fileTree = createFileTree(getNodesFromMockFs);
+
+ afterEach(() => {
+ fileTree = createFileTree(getNodesFromMockFs);
+ });
+
+ it("should take snapshot of expanded directories", async () => {
+ await waitForTree(fileTree);
+ const handleSnapshot = jest.fn();
+ renderHook(() => useFileTreeSnapshot(fileTree, handleSnapshot));
+
+ expect(handleSnapshot).not.toHaveBeenCalled();
+ const dir = fileTree.getById(fileTree.visibleNodes[0]) as Dir;
+ await fileTree.expand(dir);
+ expect(handleSnapshot).lastCalledWith({
+ expandedPaths: [dir.path],
+ buriedPaths: [],
+ version: 1,
+ });
+ });
+
+ it("should take snapshot of buried directories", async () => {
+ await waitForTree(fileTree);
+ const handleSnapshot = jest.fn();
+ renderHook(() => useFileTreeSnapshot(fileTree, handleSnapshot));
+
+ expect(handleSnapshot).not.toHaveBeenCalled();
+
+ const dir = fileTree.getById(fileTree.visibleNodes[4]) as Dir;
+ await fileTree.expand(dir, { recursive: true });
+ expect(handleSnapshot).lastCalledWith({
+ expandedPaths: ["/types/tree", "/types"],
+ buriedPaths: [],
+ version: 1,
+ });
+
+ fileTree.collapse(fileTree.getById(fileTree.visibleNodes[4]) as Dir);
+
+ expect(handleSnapshot).lastCalledWith({
+ expandedPaths: [],
+ buriedPaths: ["/types/tree"],
+ version: 1,
+ });
+ });
+});
diff --git a/src/use-file-tree-snapshot.ts b/src/use-file-tree-snapshot.ts
new file mode 100644
index 0000000..ae4b4ac
--- /dev/null
+++ b/src/use-file-tree-snapshot.ts
@@ -0,0 +1,58 @@
+import { useObservable } from ".";
+import type { FileTree } from "./file-tree";
+import { isDir } from "./file-tree";
+import type { FileTreeSnapshot } from "./types";
+
+/**
+ * Take a snapshot of the expanded and buried directories of a file tree.
+ * This snapshot can be used to restore the expanded/collapsed state of the
+ * file tree when you initially load it.
+ *
+ * @param fileTree - A file tree
+ * @param callback - A callback that handles the file tree snapshot
+ */
+export function useFileTreeSnapshot(
+ fileTree: FileTree,
+ callback: (state: FileTreeSnapshot) => Promise | void
+) {
+ useObservable(fileTree.flatView, () => {
+ const expandedPaths: string[] = [];
+ const nodeIds = [...fileTree.visibleNodes];
+ const buriedIds: number[] = [];
+ let nodeId: number | undefined;
+
+ while ((nodeId = nodeIds.pop())) {
+ const node = fileTree.getById(nodeId);
+
+ if (!node) continue;
+ if (isDir(node)) {
+ if (node.expanded) {
+ expandedPaths.push(node.path);
+ } else if (node.nodes) {
+ buriedIds.push(...node.nodes);
+ }
+ }
+ }
+
+ const buriedPaths: string[] = [];
+
+ while ((nodeId = buriedIds.pop())) {
+ const node = fileTree.getById(nodeId);
+
+ if (!node) continue;
+ if (isDir(node)) {
+ if (node.expanded) {
+ buriedPaths.push(node.path);
+ }
+
+ if (node.nodes) {
+ buriedIds.push(...node.nodes);
+ }
+ }
+ }
+
+ callback({ expandedPaths, buriedPaths, version: SNAPSHOT_VERSION });
+ });
+}
+
+const SNAPSHOT_VERSION = 1;
diff --git a/src/use-virtualize.test.ts b/src/use-virtualize.test.ts
index 9e6b43d..206b906 100644
--- a/src/use-virtualize.test.ts
+++ b/src/use-virtualize.test.ts
@@ -94,6 +94,20 @@ describe("useVirtualize()", () => {
expect(result.current.scrollTop).toBe(24 * 5);
});
+ it("should scroll to a given position", () => {
+ window.innerHeight = 48;
+
+ const { result } = renderHook(() =>
+ useVirtualize(fileTree, { windowRef: window, nodeHeight: 24 })
+ );
+
+ act(() => {
+ result.current.scrollTo(48);
+ });
+
+ expect(result.current.scrollTop).toBe(48);
+ });
+
it("should scroll to a node and center it", () => {
window.innerHeight = 24 * 3;
diff --git a/src/use-virtualize.ts b/src/use-virtualize.ts
index 3cec600..2df98ae 100644
--- a/src/use-virtualize.ts
+++ b/src/use-virtualize.ts
@@ -42,6 +42,15 @@ export function useVirtualize(
scrollTop: scrollPosition.scrollTop,
isScrolling: scrollPosition.isScrolling,
+ scrollTo(scrollTop, config = {}) {
+ const windowEl =
+ windowRef && "current" in windowRef ? windowRef.current : windowRef;
+
+ if (windowEl) {
+ windowEl.scrollTo({ top: scrollTop, behavior: config.behavior });
+ }
+ },
+
scrollToNode(nodeId, config = {}) {
const index = visibleNodes.indexOf(nodeId) ?? -1;
@@ -374,6 +383,16 @@ export interface UseVirtualizeResult {
* `true` if the viewport is currently scrolling
*/
isScrolling: boolean;
+ /**
+ * Scroll to the viewport a given position
+ *
+ * @param scrollTop - The new scroll position
+ * @param config - Configuration options
+ */
+ scrollTo(
+ scrollTop: number,
+ config?: Pick
+ ): void;
/**
* Scroll to a given node by its ID
*