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.
This commit is contained in:
Camden Cheek 2024-08-09 11:14:09 -06:00 committed by GitHub
parent 0de249daf9
commit 3df76cb173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 154 additions and 95 deletions

View File

@ -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<string, { index: number; seenPaths: Record<string, number> }> = {}
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<TreeEntry> {
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<TreeEntry> {
type Tree = Map<string, { entry: TreeEntry; tree: Tree }>
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<TreeEntry> {
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<TreeEntry> {
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
</script>
@ -256,7 +315,7 @@
{/each}
</fieldset>
<div class="outline">
{#if repoGroups.length > 0}
{#if pathGroups.length > 0}
<h4>Filter by location</h4>
<TreeView treeProvider={outlineTree} on:select={event => handleSelect(event.detail)}>
<svelte:fragment let:entry>
@ -266,7 +325,7 @@
{displayRepoName(entry.name)}
</span>
{:else}
<span class="path-entry" data-repo-name={entry.repo} data-path={entry.name}>
<span class="path-entry" data-repo-name={entry.repo} data-path={entry.path}>
{entry.name}
</span>
{/if}

View File

@ -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)}
>
<div class="header">
<CodeHostIcon repository={repo} />
<span class="repo-name"><DisplayPath path={displayRepoName(repo)} /></span>
<CodeHostIcon {repository} />
<span class="repo-name"><DisplayPath path={displayRepoName(repository)} /></span>
<span class="interpunct"></span>
<span class="file-name">
<DisplayPath
{path}
pathHref={pathHrefFactory({
repoName: repo,
repoName: repository,
revision: revision,
fullPath: path,
fullPathType: 'blob',