From 3df76cb1739e1d5ee753a3fc4105ac3ceace73da Mon Sep 17 00:00:00 2001 From: Camden Cheek Date: Fri, 9 Aug 2024 11:14:09 -0600 Subject: [PATCH] Explore panel: more granular file tree (#64372) The first version of the file tree for the revision panel had flat file names. This meant that files were not organized, they were very horizontal-space-sensitive, and you could not filter to a directory (only repo and file name). This updates the file filter to be a full tree, which I find much easier to use. --- .../src/lib/codenav/ExplorePanel.svelte | 239 +++++++++++------- .../lib/codenav/ExplorePanelFileUsages.svelte | 10 +- 2 files changed, 154 insertions(+), 95 deletions(-) diff --git a/client/web-sveltekit/src/lib/codenav/ExplorePanel.svelte b/client/web-sveltekit/src/lib/codenav/ExplorePanel.svelte index f7fba9bee1e..259329e95f8 100644 --- a/client/web-sveltekit/src/lib/codenav/ExplorePanel.svelte +++ b/client/web-sveltekit/src/lib/codenav/ExplorePanel.svelte @@ -24,95 +24,156 @@ setContext(exploreContextKey, ctx) } - interface RepoTreeEntry { - type: 'repo' - name: string - entries: PathTreeEntry[] - } - - interface PathTreeEntry { - type: 'path' - repo: string - name: string - } - - type TreeEntry = RepoTreeEntry | PathTreeEntry - + // A set of usages grouped by unique repository, revision, and path interface PathGroup { + repository: string + revision: string path: string usages: ExplorePanel_Usage[] } - interface RepoGroup { - repo: string - pathGroups: PathGroup[] - } - - function groupUsages(usages: ExplorePanel_Usage[]): RepoGroup[] { - const seenRepos: Record }> = {} - const repoGroups: RepoGroup[] = [] + // Groups all usages into consecutive groups of matching repo/rev/path. + // Maintains input order so paging in new results doesn't cause weirdness. + // + // NOTE: this expects that usages are already ordered as contiguous + // blocks for the same repository and the same file, which is a guarantee + // provided by the usages API. + function groupUsages(usages: ExplorePanel_Usage[]): PathGroup[] { + const groups: PathGroup[] = [] + let current: PathGroup | undefined = undefined for (const usage of usages) { - const repo = usage.usageRange.repository - if (seenRepos[repo] === undefined) { - seenRepos[repo] = { index: repoGroups.length, seenPaths: {} } - repoGroups.push({ repo, pathGroups: [] }) + const { repository, revision, path } = usage.usageRange + if ( + current && + current.repository === repository && + current.revision === revision && + current.path === path + ) { + current.usages.push(usage) + } else { + if (current) { + groups.push(current) + } + current = { + repository, + revision, + path, + usages: [usage], + } } - - const path = usage.usageRange.path - const seenPaths = seenRepos[repo].seenPaths - const pathGroups = repoGroups[seenRepos[repo].index].pathGroups - - if (seenPaths[path] === undefined) { - seenPaths[path] = pathGroups.length - pathGroups.push({ path, usages: [] }) - } - - pathGroups[seenPaths[path]].usages.push(usage) + } + if (current) { + groups.push(current) } - return repoGroups + return groups } - function treeProviderForEntries(entries: TreeEntry[]): TreeProvider { - return { - getNodeID(entry) { - if (entry.type === 'repo') { - return `repo-${entry.name}` - } else { - return `path-${entry.repo}-${entry.name}` + interface RepoTreeEntry { + type: 'repo' + name: string + } + + interface DirTreeEntry { + type: 'dir' + repo: string + path: string // The full path + name: string // The path element for this dir + } + + interface FileTreeEntry { + type: 'file' + repo: string + path: string // The full path + name: string // The file name + } + + const typeRanks = { repo: 0, dir: 1, file: 2 } + + type TreeEntry = RepoTreeEntry | FileTreeEntry | DirTreeEntry + + function generateTree(pathGroups: PathGroup[]): TreeProvider { + type Tree = Map + const tree: Tree = new Map() + const addToTree = (repo: string, path: string) => { + if (!tree.get(repo)) { + tree.set(repo, { entry: { type: 'repo', name: repo }, tree: new Map() }) + } + const repoEntry = tree.get(repo)! + + const pathElements = path.split('/') + const dirs = pathElements.slice(0, -1) + + let current = repoEntry + for (const [index, dir] of dirs.entries()) { + if (!current.tree.get(dir)) { + current.tree.set(dir, { + tree: new Map(), + entry: { + type: 'dir', + repo, + name: dir, + path: pathElements.slice(0, index + 1).join('/') + '/', + }, + }) } - }, - getEntries(): TreeEntry[] { - return entries - }, - isExpandable(entry) { - return entry.type === 'repo' - }, - isSelectable() { - return true - }, - fetchChildren(entry) { - if (entry.type === 'repo') { - return Promise.resolve(treeProviderForEntries(entry.entries)) - } else { - throw new Error('path nodes are not expandable') - } - }, + current = current.tree.get(dir)! + } + + const fileName = pathElements.at(-1)! // splitting will always have at least one element + current.tree.set(fileName, { + tree: new Map(), + entry: { + type: 'file', + repo, + path, + name: fileName, + }, + }) } - } - function generateOutlineTree(repoGroups: RepoGroup[]): TreeProvider { - const repoEntries: RepoTreeEntry[] = repoGroups.map(repoGroup => ({ - type: 'repo', - name: repoGroup.repo, - entries: repoGroup.pathGroups.map(pathGroup => ({ - type: 'path', - name: pathGroup.path, - repo: repoGroup.repo, - })), - })) - return treeProviderForEntries(repoEntries) + for (const pathGroup of pathGroups) { + addToTree(pathGroup.repository, pathGroup.path) + } + + function newTreeProvider(tree: Tree): TreeProvider { + return { + getNodeID(entry) { + if (entry.type === 'repo') { + return `repo-${entry.name}` + } else { + return `path-${entry.repo}-${entry.path}` + } + }, + getEntries(): TreeEntry[] { + return Array.from(tree.entries()) + .map(([_name, entry]) => entry.entry) + .toSorted((a, b) => { + // Sort directories first, then sort alphabetically + if (a.type !== b.type) { + return typeRanks[a.type] - typeRanks[b.type] + } + return a.name.localeCompare(b.name) + }) + }, + isExpandable(entry) { + return entry.type === 'repo' || entry.type === 'dir' + }, + isSelectable() { + return true + }, + fetchChildren(entry) { + if (entry.type === 'repo' || entry.type === 'dir') { + return Promise.resolve(newTreeProvider(tree.get(entry.name)!.tree)) + } else { + throw new Error('path nodes are not expandable') + } + }, + } + } + + return newTreeProvider(tree) } export function getUsagesStore(client: GraphQLClient, documentInfo: DocumentInfo, occurrence: Occurrence) { @@ -160,6 +221,13 @@ path?: string } + function matchesTreeFilter(treeFilter: TreeFilter | undefined): (pathGroup: PathGroup) => boolean { + return pathGroup => + treeFilter === undefined || + (treeFilter.repository === pathGroup.repository && + (treeFilter.path === undefined || pathGroup.path.startsWith(treeFilter.path))) + } + export function entryIDForFilter(filter: TreeFilter): string { if (filter.path) { return `path-${filter.repository}-${filter.path}` @@ -212,20 +280,11 @@ } $: loading = $connection?.fetching - $: usages = $connection?.data - $: kindFilteredUsages = usages?.filter(matchesUsageKind($inputs.usageKindFilter)) - $: repoGroups = groupUsages(kindFilteredUsages ?? []) - $: outlineTree = generateOutlineTree(repoGroups) - $: displayGroups = repoGroups - .flatMap(repoGroup => repoGroup.pathGroups.map(pathGroup => ({ repo: repoGroup.repo, ...pathGroup }))) - .filter(displayGroup => { - if ($inputs.treeFilter === undefined) { - return true - } else if ($inputs.treeFilter.repository !== displayGroup.repo) { - return false - } - return $inputs.treeFilter.path === undefined || $inputs.treeFilter.path === displayGroup.path - }) + $: usages = $connection?.data ?? [] + $: kindFilteredUsages = usages.filter(matchesUsageKind($inputs.usageKindFilter)) + $: pathGroups = groupUsages(kindFilteredUsages) + $: outlineTree = generateTree(pathGroups) + $: displayGroups = pathGroups.filter(matchesTreeFilter($inputs.treeFilter)) let referencesScroller: HTMLElement | undefined @@ -256,7 +315,7 @@ {/each}
- {#if repoGroups.length > 0} + {#if pathGroups.length > 0}

Filter by location

handleSelect(event.detail)}> @@ -266,7 +325,7 @@ {displayRepoName(entry.name)} {:else} - + {entry.name} {/if} diff --git a/client/web-sveltekit/src/lib/codenav/ExplorePanelFileUsages.svelte b/client/web-sveltekit/src/lib/codenav/ExplorePanelFileUsages.svelte index d63d2efd6be..9e809893340 100644 --- a/client/web-sveltekit/src/lib/codenav/ExplorePanelFileUsages.svelte +++ b/client/web-sveltekit/src/lib/codenav/ExplorePanelFileUsages.svelte @@ -11,7 +11,7 @@ import type { ExplorePanel_Usage } from './ExplorePanel.gql' - export let repo: string + export let repository: string export let path: string export let usages: ExplorePanel_Usage[] export let scrollContainer: HTMLElement | undefined @@ -24,7 +24,7 @@ $: if (visible) { fetchFileRangeMatches({ result: { - repository: repo, + repository, commit: revision, path: path, }, @@ -73,14 +73,14 @@ on:intersecting={event => (visible = visible || event.detail)} >
- - + +