sourcegraph/client/web-sveltekit/src/lib/utils.ts
Felix Kling f3e9e40aa0
sveltekit-prototype: Add initial version of expandable and accessible file tree (#54714)
This commit adds a reusable and accessible single select tree view, and
is used in the repo page to render the file tree sidebar.

Overview:

`TreeView.svelte` and `TreeNode.svelte` provide the generic tree
implementation. They depend on `TreeState`, which needs to be provided
as context by the caller, and `TreeProvider` which needs to be passed as
prop.

`FileTree.svelte` uses those components to provide the sidebar file
tree.

The code layout data loader fetches the file data for the current path
(or the "highest" path visited for the current directory, similar to how
the current web app works), which the code layout component uses to
create a `FileTreeProvider`.

Noteworthy:

- Tree state (focused, selected, expanded nodes) is stored globally (per
repo) so that we can restore the state when opening/closing the sidebar
or navigating back to the repo.
- I set the maximum number of fetched and shown nodes to 1000. The more
nodes we show the longer it takes for the (sub-)tree to be visible (we
have that problem in the current web app too) and I see no benefit from
showing 2500 instead of 1000 nodes (but we can always tweak this).
- Update performance (selecting nodes, change focused node) is really
good (irrespective of the number of rendered nodes (see video, compare
with current web app).
- I tried to make it fully keyboard accessible. I haven't implemented
some of the more "exotic" `aria-*` props like `aria-posinset`.
- Renders info node when not all nodes have been fetched/rendered (like
in the current web app)

What it doesn't do (yet):

- It doesn't handle single directory children. Supporting this would
require changes to data loading and representation.
- It doesn't render submodules.  
- Multiselection
- Unit tests



https://github.com/sourcegraph/sourcegraph/assets/179026/158dd428-9463-4098-bf68-fff4897e899a



## Test plan

Open repository, select a file and use arrow keys to navigate the file
tree.
2023-07-17 14:25:52 +02:00

91 lines
2.8 KiB
TypeScript

import type { Observable } from 'rxjs'
import { shareReplay } from 'rxjs/operators'
import { type Readable, type Writable, writable, get, type Unsubscriber } from 'svelte/store'
export type LoadingData<D, E = Error> =
| { loading: true }
| { loading: false; data: D; error: null }
| { loading: false; data: null; error: E }
/**
* Converts a promise to a readable store which emits data, loading and error states.
* Sometimes load functions return deferred promises and the data needs to be
* "post processed" in code (i.e. not using {#await}).
* Usually when working with async data one has to be careful with outdated data.
* If the load function has been called again we don't want to process the
* previous data anymore.
* Using a (reactive) store makes that simpler since Svelte will automatically unsubscribe
* when the store changes.
*/
export function asStore<T, E = Error>(
promise: Promise<T>
): Readable<LoadingData<T, E>> & { set(promise: Promise<T>): void } {
const { subscribe, set } = writable<LoadingData<T, E>>({ loading: true })
function process(currentPromise: Promise<T>) {
promise = currentPromise
currentPromise.then(
result => {
if (currentPromise === promise) {
set({ loading: false, data: result, error: null })
}
},
error => {
if (currentPromise === promise) {
set({ loading: false, data: null, error })
}
}
)
}
process(promise)
return {
subscribe,
set: process,
}
}
/**
* Helper function to convert an Observable to a Svelte Readable. Useful when a
* real Readable is needed to satisfy an interface.
*/
export function readableObservable<T>(observable: Observable<T>): Readable<T> {
const sharedObservable = observable.pipe(shareReplay(1))
return {
subscribe(subscriber) {
const subscription = sharedObservable.subscribe(subscriber)
return () => subscription.unsubscribe()
},
}
}
/**
* Returns a helper store that syncs with the currently set store.
*/
export function createForwardStore<T>(store: Writable<T>): Writable<T> & { updateStore(store: Writable<T>): void } {
const { subscribe, set } = writable<T>(get(store), () => link(store))
let unsubscribe: Unsubscriber | null = null
function link(store: Writable<T>): Unsubscriber {
unsubscribe?.()
return (unsubscribe = store.subscribe(set))
}
return {
subscribe,
set(value) {
store.set(value)
},
update(value) {
store.update(value)
},
updateStore(newStore) {
if (newStore !== store) {
store = newStore
link(store)
}
},
}
}