Navigating documentation panel with breadcrumbs (#8176)

Closes #8131

https://github.com/enso-org/enso/assets/6566674/69609307-d5f5-4185-af65-aed1f6b85978
This commit is contained in:
Ilya Bogdanov 2023-10-30 16:31:33 +04:00 committed by GitHub
parent a862ea7948
commit 4de51b25ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 364 additions and 17 deletions

View File

@ -1,15 +1,21 @@
<script setup lang="ts">
import type { Item as Breadcrumb } from '@/components/DocumentationPanel/DocsBreadcrumbs.vue'
import Breadcrumbs from '@/components/DocumentationPanel/DocsBreadcrumbs.vue'
import DocsExamples from '@/components/DocumentationPanel/DocsExamples.vue'
import DocsHeader from '@/components/DocumentationPanel/DocsHeader.vue'
import DocsList from '@/components/DocumentationPanel/DocsList.vue'
import DocsSynopsis from '@/components/DocumentationPanel/DocsSynopsis.vue'
import DocsTags from '@/components/DocumentationPanel/DocsTags.vue'
import { HistoryStack } from '@/components/DocumentationPanel/history'
import type { Docs, FunctionDocs, Sections, TypeDocs } from '@/components/DocumentationPanel/ir'
import { lookupDocumentation, placeholder } from '@/components/DocumentationPanel/ir'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
import type { Icon as IconName } from '@/util/iconName'
import { type Opt } from '@/util/opt'
import { computed } from 'vue'
import type { QualifiedName } from '@/util/qualifiedName'
import { qnSegments, qnSlice } from '@/util/qualifiedName'
import { computed, watch } from 'vue'
const props = defineProps<{ selectedEntry: Opt<SuggestionId> }>()
const emit = defineEmits<{ 'update:selectedEntry': [id: SuggestionId] }>()
@ -40,29 +46,113 @@ const types = computed<TypeDocs[]>(() => {
const docs = documentation.value
return docs.kind === 'Module' ? docs.types : []
})
const isPlaceholder = computed(() => 'Placeholder' in documentation.value)
const name = computed<Opt<QualifiedName>>(() => {
const docs = documentation.value
return docs.kind === 'Placeholder' ? null : docs.name
})
// === Breadcrumbs ===
const color = computed<string>(() => {
const id = props.selectedEntry
if (id) {
const entry = db.entries.get(id)
const groupIndex = entry?.groupIndex ?? -1
const group = db.groups[groupIndex]
if (group) {
const name = group.name.replace(/\s/g, '-')
return `var(--group-color-${name})`
}
}
return 'var(--group-color-fallback)'
})
const icon = computed<IconName>(() => {
const id = props.selectedEntry
if (id) {
const entry = db.entries.get(id)
return entry?.iconName ?? 'marketplace'
} else {
return 'marketplace'
}
})
const historyStack = new HistoryStack()
// Reset breadcrumbs history when the user selects the entry from the component list.
watch(
() => props.selectedEntry,
(entry) => {
if (entry && historyStack.current.value !== entry) {
historyStack.reset(entry)
}
},
)
// Update displayed documentation page when the user uses breadcrumbs.
watch(historyStack.current, (current) => {
if (current) {
emit('update:selectedEntry', current)
}
})
const breadcrumbs = computed<Breadcrumb[]>(() => {
if (name.value) {
const segments = qnSegments(name.value)
return segments.slice(1).map((s) => ({ label: s.toLowerCase() }))
} else {
return []
}
})
function handleBreadcrumbClick(index: number) {
if (name.value) {
const qName = qnSlice(name.value, 0, index + 2)
if (qName.ok) {
const [id] = db.entries.nameToId.lookup(qName.value)
if (id) {
historyStack.record(id)
}
}
}
}
</script>
<template>
<div class="DocumentationPanel scrollable" @wheel.stop.passive>
<h1 v-if="documentation.kind === 'Placeholder'">{{ documentation.text }}</h1>
<DocsTags v-if="sections.tags.length > 0" :tags="sections.tags" />
<Breadcrumbs
v-if="!isPlaceholder"
:breadcrumbs="breadcrumbs"
:color="color"
:icon="icon"
:canGoForward="historyStack.canGoForward()"
:canGoBackward="historyStack.canGoBackward()"
@click="(index) => handleBreadcrumbClick(index)"
@forward="historyStack.forward()"
@backward="historyStack.backward()"
/>
<DocsTags v-if="sections.tags.length > 0" class="tags" :tags="sections.tags" />
<div class="sections">
<span v-if="sections.synopsis.length == 0">{{ 'No documentation available.' }}</span>
<DocsSynopsis :sections="sections.synopsis" />
<DocsHeader v-if="types.length > 0" kind="types" label="Types" />
<DocsList
:items="{ kind: 'Types', items: types }"
@linkClicked="emit('update:selectedEntry', $event)"
@linkClicked="historyStack.record($event)"
/>
<DocsHeader v-if="constructors.length > 0" kind="methods" label="Constructors" />
<DocsList
:items="{ kind: 'Constructors', items: constructors }"
@linkClicked="emit('update:selectedEntry', $event)"
@linkClicked="historyStack.record($event)"
/>
<DocsHeader v-if="methods.length > 0" kind="methods" label="Methods" />
<DocsList
:items="{ kind: 'Methods', items: methods }"
@linkClicked="emit('update:selectedEntry', $event)"
@linkClicked="historyStack.record($event)"
/>
<DocsHeader v-if="sections.examples.length > 0" kind="examples" label="Examples" />
<DocsExamples :examples="sections.examples" />
@ -89,11 +179,18 @@ const types = computed<TypeDocs[]>(() => {
line-height: 160%;
color: var(--enso-docs-text-color);
background-color: var(--enso-docs-background-color);
padding: 8px 12px 4px 8px;
padding: 4px 12px 4px 4px;
white-space: normal;
clip-path: inset(0 0 4px 0);
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.tags {
margin: 4px 0 0 8px;
}
.sections {

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import type { Icon } from '@/util/iconName'
const props = defineProps<{ text: string; icon?: Icon | undefined }>()
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<div class="Breadcrumb">
<SvgIcon v-if="props.icon" :name="props.icon || ''" />
<span @pointerdown="emit('click')" v-text="props.text"></span>
</div>
</template>
<style scoped>
.Breadcrumb {
user-select: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
overflow-x: hidden;
}
span {
display: inline-block;
height: 24px;
padding: 1px 0px;
color: white;
font-weight: 500;
font-style: normal;
line-height: 20px;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
svg {
color: white;
}
</style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import type { Icon } from '@/util/iconName'
import Breadcrumb from '@/components/DocumentationPanel/DocsBreadcrumb.vue'
export interface Item {
label: string
}
const props = defineProps<{
breadcrumbs: Item[]
color: string
icon: Icon
canGoForward: boolean
canGoBackward: boolean
}>()
const emit = defineEmits<{ click: [index: number]; backward: []; forward: [] }>()
/**
* Shrink first and middle elements in the breacrumbs, keeping the original size of others.
*/
function shrinkFactor(index: number): number {
const middle = Math.floor(props.breadcrumbs.length / 2)
return index === middle || index === 0 ? 100 : 0
}
</script>
<template>
<div class="Breadcrumbs" :style="{ 'background-color': color }">
<div class="breadcrumbs-controls">
<SvgIcon
name="arrow_left"
draggable="false"
:class="['icon', 'button', 'arrow', { inactive: !props.canGoBackward }]"
@pointerdown="emit('backward')"
/>
<SvgIcon
name="arrow_right"
draggable="false"
:class="['icon', 'button', 'arrow', { inactive: !props.canGoForward }]"
@pointerdown="emit('forward')"
/>
</div>
<TransitionGroup name="breadcrumbs">
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="[index, breadcrumb.label]">
<SvgIcon v-if="index > 0" name="arrow_right_head_only" class="arrow" />
<Breadcrumb
:text="breadcrumb.label"
:icon="index === props.breadcrumbs.length - 1 ? props.icon : undefined"
:style="{ 'flex-shrink': shrinkFactor(index) }"
@click="emit('click', index)"
/>
</template>
</TransitionGroup>
</div>
</template>
<style scoped>
.Breadcrumbs {
display: flex;
height: 32px;
padding: 8px 10px 8px 8px;
align-items: center;
gap: 2px;
border-radius: 16px;
transition: background-color 0.5s;
max-width: 100%;
}
.breadcrumbs-controls {
display: flex;
}
.inactive {
opacity: 0.3;
}
.arrow {
color: white;
transition: opacity 0.5s;
}
.breadcrumbs-move,
.breadcrumbs-enter-active,
.breadcrumb-leave-active {
transition: all 0.3s ease;
}
.breadcrumbs-leave-active {
display: none;
}
.breadcrumbs-enter-from {
opacity: 0;
}
</style>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FunctionDocs, TypeDocs } from '@/components/DocumentationPanel/ir'
import type { Doc } from '@/util/docParser'
import { qnSplit } from '@/util/qualifiedName'
import type { SuggestionEntryArgument, SuggestionId } from 'shared/languageServerTypes/suggestions'
import { computed } from 'vue'
@ -33,7 +34,7 @@ function firstParagraph(synopsis: Doc.Section[]): string | undefined {
function argumentsList(args: SuggestionEntryArgument[]): string {
return args
.map((arg) => {
const defaultValue = arg.defaultValue ? ` = ${arg.defaultValue}` : ''
const defaultValue = arg.defaultValue ? `=${arg.defaultValue}` : ''
return `${arg.name}${defaultValue}`
})
.join(', ')
@ -51,7 +52,7 @@ const annotations = computed<Array<string | undefined>>(() => {
:class="['link', props.items.kind]"
@pointerdown.stop.prevent="emit('linkClicked', item.id)"
>
<span class="entryName">{{ item.name }}</span>
<span class="entryName">{{ qnSplit(item.name)[1] }}</span>
<span class="arguments">{{ ' ' + argumentsList(item.arguments) }}</span>
</a>
<!-- eslint-disable vue/no-v-html -->

View File

@ -0,0 +1,97 @@
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
import type { ComputedRef, Ref } from 'vue'
import { computed, reactive, ref } from 'vue'
/**
* Simple stack for going forward and backward through the history of visited documentation pages
*/
export class HistoryStack {
private stack: SuggestionId[]
private index: Ref<number>
public current: ComputedRef<SuggestionId | undefined>
constructor() {
this.stack = reactive([])
this.index = ref(0)
this.current = computed(() => this.stack[this.index.value] ?? undefined)
}
public reset(current: SuggestionId) {
this.stack.length = 0
this.stack.push(current)
this.index.value = 0
}
public record(id: SuggestionId) {
this.stack.splice(this.index.value + 1)
this.stack.push(id)
this.index.value = this.stack.length - 1
}
public forward() {
if (this.canGoForward()) {
this.index.value += 1
}
}
public backward() {
if (this.canGoBackward()) {
this.index.value -= 1
}
}
public canGoBackward(): boolean {
return this.index.value > 0
}
public canGoForward(): boolean {
return this.index.value < this.stack.length - 1
}
}
if (import.meta.vitest) {
const { test, expect } = import.meta.vitest
const ID_1 = 10
const ID_2 = 20
const ID_3 = 30
test('HistoryStack basic operations', () => {
const stack = new HistoryStack()
expect(stack.current.value).toBeUndefined()
expect(stack.canGoBackward()).toBeFalsy()
expect(stack.canGoForward()).toBeFalsy()
stack.reset(ID_1)
expect(stack.current.value).toStrictEqual(ID_1)
stack.forward()
expect(stack.current.value).toStrictEqual(ID_1)
expect(stack.canGoBackward()).toBeFalsy()
expect(stack.canGoForward()).toBeFalsy()
stack.record(ID_2)
expect(stack.current.value).toStrictEqual(ID_2)
expect(stack.canGoBackward()).toBeTruthy()
expect(stack.canGoForward()).toBeFalsy()
stack.backward()
expect(stack.current.value).toStrictEqual(ID_1)
expect(stack.canGoBackward()).toBeFalsy()
expect(stack.canGoForward()).toBeTruthy()
stack.backward()
expect(stack.current.value).toStrictEqual(ID_1)
stack.forward()
expect(stack.current.value).toStrictEqual(ID_2)
expect(stack.canGoForward()).toBeFalsy()
stack.forward()
expect(stack.current.value).toStrictEqual(ID_2)
stack.backward()
expect(stack.current.value).toStrictEqual(ID_1)
stack.record(ID_3)
expect(stack.current.value).toStrictEqual(ID_3)
stack.forward()
expect(stack.current.value).toStrictEqual(ID_3)
stack.backward()
expect(stack.current.value).toStrictEqual(ID_1)
})
}

View File

@ -1,7 +1,8 @@
import type { SuggestionDb } from '@/stores/suggestionDatabase'
import type { SuggestionEntry, SuggestionId } from '@/stores/suggestionDatabase/entry'
import { SuggestionKind } from '@/stores/suggestionDatabase/entry'
import { SuggestionKind, entryQn } from '@/stores/suggestionDatabase/entry'
import type { Doc } from '@/util/docParser'
import type { QualifiedName } from '@/util/qualifiedName'
import type { SuggestionEntryArgument } from 'shared/languageServerTypes/suggestions'
// === Types ===
@ -20,7 +21,7 @@ export interface Placeholder {
export interface FunctionDocs {
kind: 'Function'
id: SuggestionId
name: string
name: QualifiedName
arguments: SuggestionEntryArgument[]
sections: Sections
}
@ -28,7 +29,7 @@ export interface FunctionDocs {
export interface TypeDocs {
kind: 'Type'
id: SuggestionId
name: string
name: QualifiedName
arguments: SuggestionEntryArgument[]
sections: Sections
methods: FunctionDocs[]
@ -38,7 +39,7 @@ export interface TypeDocs {
export interface ModuleDocs {
kind: 'Module'
id: SuggestionId
name: string
name: QualifiedName
sections: Sections
types: TypeDocs[]
methods: FunctionDocs[]
@ -47,7 +48,7 @@ export interface ModuleDocs {
export interface LocalDocs {
kind: 'Local'
id: SuggestionId
name: string
name: QualifiedName
sections: Sections
}
@ -144,7 +145,7 @@ type DocsHandle = (db: SuggestionDb, entry: SuggestionEntry, id: SuggestionId) =
const handleFunction: DocsHandle = (_db, entry, id) => ({
kind: 'Function',
id,
name: entry.name,
name: entryQn(entry),
arguments: entry.arguments,
sections: filterSections(entry.documentation),
})
@ -156,13 +157,13 @@ const handleDocumentation: Record<SuggestionKind, DocsHandle> = {
[SuggestionKind.Local]: (_db, entry, id) => ({
kind: 'Local',
id,
name: entry.name,
name: entryQn(entry),
sections: filterSections(entry.documentation),
}),
[SuggestionKind.Type]: (db, entry, id) => ({
kind: 'Type',
id,
name: entry.name,
name: entryQn(entry),
arguments: entry.arguments,
sections: filterSections(entry.documentation),
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),
@ -171,7 +172,7 @@ const handleDocumentation: Record<SuggestionKind, DocsHandle> = {
[SuggestionKind.Module]: (db, entry, id) => ({
kind: 'Module',
id,
name: entry.name,
name: entryQn(entry),
sections: filterSections(entry.documentation),
types: asTypeDocs(getChildren(db, id, SuggestionKind.Type)),
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),

View File

@ -63,6 +63,18 @@ export function qnJoin(left: QualifiedName, right: QualifiedName): QualifiedName
return `${left}.${right}` as QualifiedName
}
export function qnSegments(name: QualifiedName): Identifier[] {
return name.split('.').map((segment) => segment as Identifier)
}
export function qnSlice(
name: QualifiedName,
start?: number | undefined,
end?: number | undefined,
): Result<QualifiedName> {
return tryQualifiedName(qnSegments(name).slice(start, end).join('.'))
}
/** Checks if given full qualified name is considered a top element of some project.
*
* The fully qualified names consists of namespace, project name, and then a path (possibly empty).