Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.

Commit

Permalink
Explore panel: more granular file tree (#64372)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
camdencheek authored Aug 9, 2024
1 parent 0de249d commit 3df76cb
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 90 deletions.
229 changes: 144 additions & 85 deletions client/web-sveltekit/src/lib/codenav/ExplorePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,95 +24,156 @@
setContext(exploreContextKey, ctx)
}
// A set of usages grouped by unique repository, revision, and path
interface PathGroup {
repository: string
revision: string
path: string
usages: ExplorePanel_Usage[]
}
// 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 { 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],
}
}
}
if (current) {
groups.push(current)
}
return groups
}
interface RepoTreeEntry {
type: 'repo'
name: string
entries: PathTreeEntry[]
}
interface PathTreeEntry {
type: 'path'
interface DirTreeEntry {
type: 'dir'
repo: string
name: string
path: string // The full path
name: string // The path element for this dir
}
type TreeEntry = RepoTreeEntry | PathTreeEntry
interface PathGroup {
path: string
usages: ExplorePanel_Usage[]
}
interface RepoGroup {
interface FileTreeEntry {
type: 'file'
repo: string
pathGroups: PathGroup[]
path: string // The full path
name: string // The file name
}
function groupUsages(usages: ExplorePanel_Usage[]): RepoGroup[] {
const seenRepos: Record<string, { index: number; seenPaths: Record<string, number> }> = {}
const repoGroups: RepoGroup[] = []
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 typeRanks = { repo: 0, dir: 1, file: 2 }
const path = usage.usageRange.path
const seenPaths = seenRepos[repo].seenPaths
const pathGroups = repoGroups[seenRepos[repo].index].pathGroups
type TreeEntry = RepoTreeEntry | FileTreeEntry | DirTreeEntry
if (seenPaths[path] === undefined) {
seenPaths[path] = pathGroups.length
pathGroups.push({ path, usages: [] })
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('/') + '/',
},
})
}
current = current.tree.get(dir)!
}
pathGroups[seenPaths[path]].usages.push(usage)
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,
},
})
}
return repoGroups
}
for (const pathGroup of pathGroups) {
addToTree(pathGroup.repository, pathGroup.path)
}
function treeProviderForEntries(entries: TreeEntry[]): TreeProvider<TreeEntry> {
return {
getNodeID(entry) {
if (entry.type === 'repo') {
return `repo-${entry.name}`
} else {
return `path-${entry.repo}-${entry.name}`
}
},
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')
}
},
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')
}
},
}
}
}
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)
return newTreeProvider(tree)
}
export function getUsagesStore(client: GraphQLClient, documentInfo: DocumentInfo, occurrence: Occurrence) {
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +24,7 @@
$: if (visible) {
fetchFileRangeMatches({
result: {
repository: repo,
repository,
commit: revision,
path: path,
},
Expand Down Expand Up @@ -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',
Expand Down

0 comments on commit 3df76cb

Please sign in to comment.