svelte: Remove full page scrolling of repo pages (#62024)

This commit removes the full page scrolling behavior for repo root,
directory and file pages. The implementation had several issues.

In the new implementation the scroll container is CodeMirror itself. It
provides the following behavior:

- Show the top of the file when navigating to a new file.
- Scroll the selected line into view when opening a URL containing a
  position.
- Do not scroll when selecting a line (currently broken).
- Restore previous scroll position when navigating backward or forward
  in the browser history.
- Toggling the blame view maintains the current line view (well, almost but 
  that's as best as we can do at the moment).

Additional changes in this commit:

- Removed logic to enable/disable full page scrolling.
- Remove CSS for making elements `sticky`.
- Update "scroll selected line into view" logic to prevent scrolling
  when restoring the previous scroll position.
- Update `codemirror/view` to include a recent fix for a scroll related
  bug.
This commit is contained in:
Felix Kling 2024-04-19 00:04:28 +02:00 committed by GitHub
parent a2b170dc9e
commit ff1ea373c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 491 additions and 236 deletions

View File

@ -28,8 +28,6 @@
languages: string[]
}
const extensionsCompartment = new Compartment()
const defaultTheme = EditorView.theme({
'&': {
height: '100%',
@ -42,6 +40,7 @@
lineHeight: '1rem',
fontFamily: 'var(--code-font-family)',
fontSize: 'var(--code-font-size)',
overflow: 'auto',
},
'.cm-content': {
padding: 0,
@ -106,20 +105,12 @@
defaultTheme,
linkify,
]
function configureSyntaxHighlighting(content: string, lsif: string): Extension {
return lsif ? syntaxHighlight.of({ content, lsif }) : []
}
function configureMiscSettings({ wrapLines }: { wrapLines: boolean }): Extension {
return [wrapLines ? EditorView.lineWrapping : []]
}
</script>
<script lang="ts">
import '$lib/highlight.scss'
import { Compartment, EditorState, type Extension } from '@codemirror/state'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { createEventDispatcher, onMount } from 'svelte'
@ -136,14 +127,22 @@
linkify,
createCodeIntelExtension,
syncSelection,
temporaryTooltip,
showBlame as showBlameColumn,
blameData as blameDataFacet,
type BlameHunkData,
lockFirstVisibleLine,
temporaryTooltip,
} from '$lib/web'
import BlameDecoration from './blame/BlameDecoration.svelte'
import { type Range, staticHighlights } from './codemirror/static-highlights'
import {
createCompartments,
restoreScrollSnapshot,
type ExtensionType,
type ScrollSnapshot,
getScrollSnapshot as getScrollSnapshot_internal,
} from './codemirror/utils'
import { goToDefinition, openImplementations, openReferences } from './repo/blob'
export let blobInfo: BlobInfo
@ -152,22 +151,35 @@
export let selectedLines: LineOrPositionOrRange | null = null
export let codeIntelAPI: CodeIntelAPI
export let staticHighlightRanges: Range[] = []
/**
* The initial scroll position when the editor is first mounted.
* Changing the value afterwards has no effect.
*/
export let initialScrollPosition: ScrollSnapshot | null = null
export let showBlame: boolean = false
export let blameData: BlameHunkData | undefined = undefined
export function getScrollSnapshot(): ScrollSnapshot | null {
return view ? getScrollSnapshot_internal(view) : null
}
const dispatch = createEventDispatcher<{ selectline: SelectedLineRange }>()
let editor: EditorView
let container: HTMLDivElement | null = null
const lineNumbers = selectableLineNumbers({
onSelection(range) {
dispatch('selectline', range)
},
initialSelection: selectedLines?.line === undefined ? null : selectedLines,
const extensionsCompartment = createCompartments({
selectableLineNumbers: null,
syntaxHighlighting: null,
lineWrapping: null,
temporaryTooltip,
codeIntelExtension: null,
staticExtensions,
staticHighlightExtension: null,
blameDataExtension: null,
blameColumnExtension: null,
})
let container: HTMLDivElement | null = null
let view: EditorView | undefined = undefined
$: documentInfo = {
repoName: blobInfo.repoName,
commitID: blobInfo.commitID,
@ -198,8 +210,8 @@
}
},
})
$: settings = configureMiscSettings({ wrapLines })
$: sh = configureSyntaxHighlighting(blobInfo.content, highlights)
$: lineWrapping = wrapLines ? EditorView.lineWrapping : []
$: syntaxHighlighting = highlights ? syntaxHighlight.of({ content: blobInfo.content, lsif: highlights }) : []
$: staticHighlightExtension = staticHighlights(staticHighlightRanges)
$: blameColumnExtension = showBlame
@ -216,51 +228,96 @@
: []
$: blameDataExtension = blameDataFacet(blameData)
$: extensions = [
sh,
settings,
lineNumbers,
temporaryTooltip,
codeIntelExtension,
staticExtensions,
staticHighlightExtension,
blameColumnExtension,
blameDataExtension,
]
function update(blobInfo: BlobInfo, extensions: Extension, range: LineOrPositionOrRange | null) {
if (editor) {
// TODO(fkling): Find a way to combine this into a single transaction.
if (editor.state.sliceDoc() !== blobInfo.content) {
editor.setState(
EditorState.create({ doc: blobInfo.content, extensions: extensionsCompartment.of(extensions) })
)
} else {
editor.dispatch({ effects: [extensionsCompartment.reconfigure(extensions)] })
}
editor.dispatch({
effects: setSelectedLines.of(range?.line && isValidLineRange(range, editor.state.doc) ? range : null),
})
if (range) {
syncSelection(editor, range)
}
// Reinitialize the editor when its content changes. Update only the extensions when they change.
$: update(view => {
// blameColumnExtension is omitted here. It's updated separately below because we need to
// apply additional effects when it changes (but only when it changes).
const extensions: Partial<ExtensionType<typeof extensionsCompartment>> = {
codeIntelExtension,
lineWrapping,
syntaxHighlighting,
staticHighlightExtension,
blameDataExtension,
}
}
if (view.state.sliceDoc() !== blobInfo.content) {
view.setState(createEditorState(blobInfo, extensions))
} else {
extensionsCompartment.update(view, extensions)
}
})
$: update(blobInfo, extensions, selectedLines)
// Show/hide the blame column and ensure that the style changes do not change the scroll position
$: update(view => {
extensionsCompartment.update(view, { blameColumnExtension }, ...lockFirstVisibleLine(view))
})
// Update the selected lines. This will scroll the selected lines into view. Also set the editor's
// selection (essentially the cursor position) to the selected lines. This is necessary in case the
// selected range references a symbol.
$: update(view => {
view.dispatch({
effects: setSelectedLines.of(
selectedLines?.line && isValidLineRange(selectedLines, view.state.doc) ? selectedLines : null
),
})
if (selectedLines) {
syncSelection(view, selectedLines)
}
})
onMount(() => {
if (container) {
editor = new EditorView({
state: EditorState.create({ doc: blobInfo.content, extensions: extensionsCompartment.of(extensions) }),
view = new EditorView({
// On first render initialize all extensions
state: createEditorState(blobInfo, {
codeIntelExtension,
lineWrapping,
syntaxHighlighting,
staticHighlightExtension,
blameDataExtension,
blameColumnExtension,
}),
parent: container,
})
if (selectedLines) {
syncSelection(editor, selectedLines)
syncSelection(view, selectedLines)
}
if (initialScrollPosition) {
restoreScrollSnapshot(view, initialScrollPosition)
}
}
return () => {
view?.destroy()
}
})
// Helper function to update the editor state whithout depending on the view variable
// (those updates should only run on subsequent updates)
function update(updater: (view: EditorView) => void) {
if (view) {
updater(view)
}
}
function createEditorState(blobInfo: BlobInfo, extensions: Partial<ExtensionType<typeof extensionsCompartment>>) {
return EditorState.create({
doc: blobInfo.content,
extensions: extensionsCompartment.init({
selectableLineNumbers: selectableLineNumbers({
onSelection(range) {
dispatch('selectline', range)
},
initialSelection: selectedLines?.line === undefined ? null : selectedLines,
// We don't want to scroll the selected line into view when a scroll position is explicitly set.
skipInitialScrollIntoView: initialScrollPosition !== null,
}),
...extensions,
}),
selection: {
anchor: 0,
},
})
}
</script>
{#if browser}
@ -273,9 +330,10 @@
<style lang="scss">
.root {
display: contents;
--blame-decoration-width: 400px;
--blame-recency-width: 4px;
height: 100%;
}
pre {
margin: 0;

View File

@ -1,59 +1,122 @@
import { Compartment, type StateEffect, type Extension } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import { EditorView } from '@codemirror/view'
type Extensions = Record<string, Extension>
type UpdatedExtensions<T extends Extensions> = { [key in keyof T]: Extension }
type Extensions = Record<string, Extension | null>
type UpdatedExtensions<T extends Extensions> = { [key in keyof T]?: Extension | null }
export type ExtensionType<T> = T extends Compartments<infer U> ? UpdatedExtensions<U> : never
interface Compartments<T extends Extensions> {
extension: Extension
/**
* Initialize compartments with a different value
* Initialize compartments with a different value.
*
* @param extensions The values for the compartments
* @returns An extension
*/
init(extensions: UpdatedExtensions<T>): Extension
/**
* Update compartments. Only compartments for which the provided
* value has changed will be updated.
* Update compartments. Only compartments for which the provided value has changed will be updated.
* Additional effects can be provided to be dispatched with the compartment updates, but they will
* only be dispatched if at least one compartment has changed.
*
* @param view The editor view
* @param extensions The updated values for the compartments
* @param additionalEffects Additional effects to be dispatched with the compartment updates
*/
update(view: EditorView, extensions: UpdatedExtensions<T>): void
update(view: EditorView, extensions: UpdatedExtensions<T>, ...additionalEffects: StateEffect<unknown>[]): void
}
const emptyExtension: Extension = []
/**
* Helper function for creating a compartments extension. Each record
* entry will get its own compartment and the value will be the initial
* value of the compartment.
* The order/presedence of the extensions is determined by the order of the
* keys in the initialExtensions record.
*
* @param initialExtensions Initial values for the compartments
* @returns Compartments extension
*/
export function createCompartments<T extends Record<string, Extension>>(extensions: T): Compartments<T> {
const compartments: Record<string, Compartment> = {}
export function createCompartments<T extends Extensions>(initialExtensions: T): Compartments<T> {
const compartments: Map<string, Compartment> = new Map()
function init(extensions: UpdatedExtensions<T>, compartments: Map<string, Compartment>): Extension {
const values: Map<string, Extension> = new Map()
function init(extensions: UpdatedExtensions<T>, compartments: Record<string, Compartment>): Extension {
const extension: Extension[] = []
for (const [name, ext] of Object.entries(extensions)) {
let compartment = compartments[name]
let compartment = compartments.get(name)
if (!compartment) {
compartment = compartments[name] = new Compartment()
compartments.set(name, (compartment = new Compartment()))
}
extension.push(compartment.of(ext))
values.set(name, compartment.of(ext ?? emptyExtension))
}
return extension
// Return extensions in the order of the initialExtensions record
return Array.from(compartments.keys(), name => values.get(name) ?? emptyExtension)
}
return {
extension: init(extensions, compartments),
extension: init(initialExtensions, compartments),
init(extensions) {
return init(extensions, compartments)
return init({ ...initialExtensions, ...extensions }, compartments)
},
update(view, extensions) {
update(view, extensions, ...additionalEffects) {
const effects: StateEffect<unknown>[] = []
for (const [name, ext] of Object.entries(extensions)) {
if (compartments[name].get(view.state) !== ext) {
effects.push(compartments[name].reconfigure(ext))
const compartment = compartments.get(name)
if (compartment && compartment.get(view.state) !== ext) {
effects.push(compartment.reconfigure(ext ?? emptyExtension))
}
}
if (effects.length > 0) {
view.dispatch({ effects })
view.dispatch({ effects: effects.concat(additionalEffects) })
}
},
}
}
/**
* An object representing the scroll state of a CodeMirror instance.
*/
export interface ScrollSnapshot {
scrollTop?: number
}
/**
* Returns a snapshot of the editors scroll state that is serializable (unlike CodeMirror's
* native scroll snapshot).
*
* @param view The editor view
* @returns The scroll snapshot
*/
export function getScrollSnapshot(view: EditorView): ScrollSnapshot {
return {
scrollTop: view.scrollDOM.scrollTop,
}
}
/**
* Restores the scroll state of the editor from a snapshot.
*
* @param view The editor view
* @param snapshot The scroll snapshot
*/
export function restoreScrollSnapshot(view: EditorView, snapshot: ScrollSnapshot): void {
const { scrollTop } = snapshot
if (scrollTop !== undefined) {
// We are using request measure here to ensure that the DOM has been updated
// before updating the scroll position.
view.requestMeasure({
read() {
return null
},
write(_measure, view) {
view.scrollDOM.scrollTop = scrollTop
},
})
}
}

View File

@ -74,8 +74,6 @@
align-items: baseline;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0px;
background-color: var(--color-bg-1);
z-index: 1;
gap: 0.5rem;

View File

@ -81,5 +81,3 @@ export function createLocalWritable<T>(localStorageKey: string, defaultValue: T)
},
}
}
export const scrollAll = writable(false)

View File

@ -10,6 +10,7 @@ export { linkify } from '@sourcegraph/web/src/repo/blob/codemirror/links'
export { createCodeIntelExtension } from '@sourcegraph/web/src/repo/blob/codemirror/codeintel/extension'
export type { TooltipViewOptions } from '@sourcegraph/web/src/repo/blob/codemirror/codeintel/api'
export { positionToOffset, locationToURL } from '@sourcegraph/web/src/repo/blob/codemirror/utils'
export { lockFirstVisibleLine } from '@sourcegraph/web/src/repo/blob/codemirror/lock-line'
export { syncSelection } from '@sourcegraph/web/src/repo/blob/codemirror/codeintel/token-selection'
export {
showTemporaryTooltip,

View File

@ -3,9 +3,8 @@
import { browser } from '$app/environment'
import { isErrorLike } from '$lib/common'
import { classNames } from '$lib/dom'
import { TemporarySettingsStorage } from '$lib/shared'
import { isLightTheme, setAppContext, scrollAll } from '$lib/stores'
import { isLightTheme, setAppContext } from '$lib/stores'
import { createTemporarySettingsStorage } from '$lib/temporarySettings'
import Header from './Header.svelte'
@ -93,8 +92,6 @@
<meta name="description" content="Code search" />
</svelte:head>
<svelte:body use:classNames={$scrollAll ? '' : 'overflowHidden'} />
{#await data.globalSiteAlerts then globalSiteAlerts}
{#if globalSiteAlerts}
<GlobalNotification globalAlerts={globalSiteAlerts} />
@ -108,15 +105,11 @@
</main>
<style lang="scss">
:global(body.overflowHidden) {
display: flex;
flex-direction: column;
:global(body) {
height: 100vh;
overflow: hidden;
main {
overflow-y: auto;
}
display: flex;
flex-direction: column;
}
main {
@ -125,5 +118,6 @@
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow-y: auto;
}
</style>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import { tick } from 'svelte'
import { afterNavigate, disableScrollHandling, goto } from '$app/navigation'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { isErrorLike } from '$lib/common'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
@ -11,7 +11,6 @@
import SidebarToggleButton from '$lib/repo/SidebarToggleButton.svelte'
import { sidebarOpen } from '$lib/repo/stores'
import Separator, { getSeparatorPosition } from '$lib/Separator.svelte'
import { scrollAll } from '$lib/stores'
import TabPanel from '$lib/TabPanel.svelte'
import Tabs from '$lib/Tabs.svelte'
import { Alert } from '$lib/wildcard'
@ -67,7 +66,6 @@
const fileTreeStore = createFileTreeStore({ fetchFileTreeData: fetchSidebarFileTree })
let selectedTab: number | null = null
let historyPanel: HistoryPanel
let rootElement: HTMLElement | null = null
let commitHistory: GitHistory_HistoryConnection | null
let lastCommit: LastCommitFragment | null
@ -94,37 +92,9 @@
const sidebarSize = getSeparatorPosition('repo-sidebar', 0.2)
$: sidebarWidth = `max(200px, ${$sidebarSize * 100}%)`
onMount(() => {
// We want the whole page to be scrollable and hide page and repo navigation
scrollAll.set(true)
return () => scrollAll.set(false)
})
afterNavigate(() => {
// When navigating to a new page we want to ensure two things:
// - The file sidebar doesn't move. It feels bad when you clicked on a file entry
// and the click target moves away because the page is scrolled all the way to the top.
// - The beginning of the content should be visible (e.g. the top of the file or the
// top of the file table).
// In other words, we want to scroll to the top but not all the way
// Prevents SvelteKit from resetting the scroll position to the very top of the page
disableScrollHandling()
if (rootElement) {
// Because the whole page is scrollable we can get the current scroll position from
// the window object
const top = rootElement.offsetTop
if (window.scrollY > top) {
// Reset scroll to top of the content
window.scrollTo(0, top)
}
}
})
</script>
<section bind:this={rootElement}>
<section>
<div class="sidebar" class:open={$sidebarOpen} style:min-width={sidebarWidth} style:max-width={sidebarWidth}>
<header>
<h3>
@ -182,9 +152,8 @@
section {
display: flex;
flex: 1;
flex-shrink: 0;
background-color: var(--code-bg);
min-height: 100vh;
overflow: hidden;
}
header {
@ -205,9 +174,6 @@
background-color: var(--body-bg);
padding: 0.5rem;
padding-bottom: 0;
position: sticky;
top: 0;
max-height: 100vh;
}
.main {
@ -215,6 +181,7 @@
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
h3 {
@ -232,8 +199,6 @@
}
.bottom-panel {
position: sticky;
bottom: 0px;
background-color: var(--code-bg);
--align-tabs: flex-start;
border-top: 1px solid var(--border-color);

View File

@ -6,7 +6,7 @@
import { from } from 'rxjs'
import { writable } from 'svelte/store'
import { goto, preloadData } from '$app/navigation'
import { afterNavigate, goto, preloadData } from '$app/navigation'
import { page } from '$app/stores'
import CodeMirrorBlob from '$lib/CodeMirrorBlob.svelte'
import { isErrorLike, SourcegraphURL, type LineOrPositionOrRange, pluralize } from '$lib/common'
@ -23,17 +23,31 @@
import { Alert, MenuButton, MenuLink } from '$lib/wildcard'
import markdownStyles from '$lib/wildcard/Markdown.module.scss'
import type { PageData } from './$types'
import type { PageData, Snapshot } from './$types'
import FileViewModeSwitcher from './FileViewModeSwitcher.svelte'
import OpenInCodeHostAction from './OpenInCodeHostAction.svelte'
import OpenInEditor from '$lib/repo/open-in-editor/OpenInEditor.svelte'
import { toViewMode, ViewMode } from './util'
import type { ScrollSnapshot } from '$lib/codemirror/utils'
export let data: PageData
export const snapshot: Snapshot<ScrollSnapshot | null> = {
capture() {
return cmblob?.getScrollSnapshot() ?? null
},
restore(data) {
initialScrollPosition = data
},
}
const combinedBlobData = createBlobDataHandler()
let selectedPosition: LineOrPositionOrRange | null = null
const lineWrap = writable<boolean>(false)
let cmblob: CodeMirrorBlob | null = null
let blob: Awaited<PageData['blob']> | null = null
let highlights: Awaited<PageData['highlights']> = ''
let selectedPosition: LineOrPositionOrRange | null = null
let initialScrollPosition: ScrollSnapshot | null = null
$: ({
repoURL,
@ -45,25 +59,37 @@
graphQLClient,
blameData,
} = data)
$: viewMode = toViewMode($page.url.searchParams.get('view'))
$: combinedBlobData.set(data.blob, data.highlights)
$: ({ blob, highlights, blobPending } = $combinedBlobData)
$: isFormatted = !!blob?.richHTML
$: fileNotFound = !blob && !blobPending
$: fileLoadingError = (!blobPending && !blob && $combinedBlobData.blobError) || null
$: showRaw = $page.url.searchParams.get('view') === 'raw'
$: if (!$combinedBlobData.blobPending) {
blob = $combinedBlobData.blob
highlights = $combinedBlobData.highlights
selectedPosition = SourcegraphURL.from($page.url).lineRange
}
$: fileNotFound = $combinedBlobData.blobPending ? null : !$combinedBlobData.blob
$: fileLoadingError = $combinedBlobData.blobPending ? null : !$combinedBlobData.blob && $combinedBlobData.blobError
$: isFormatted = !!$combinedBlobData.blob?.richHTML
$: viewMode = toViewMode($page.url.searchParams.get('view'))
$: showBlame = viewMode === ViewMode.Blame
$: showFormatted = isFormatted && viewMode === ViewMode.Default && !showBlame
$: codeIntelAPI = createCodeIntelAPI({
settings: setting => (isErrorLike(settings?.final) ? undefined : settings?.final?.[setting]),
requestGraphQL(options) {
return from(graphQLClient.query(options.request, options.variables).then(toGraphQLResult))
},
})
$: if (!blobPending) {
// Update selected position as soon as blob is loaded
selectedPosition = SourcegraphURL.from($page.url).lineRange
}
$: showBlame = viewMode === ViewMode.Blame
afterNavigate(event => {
// Only restore scroll position when the user used the browser history to navigate back
// and forth. When the user reloads the page, in which case SvelteKit will also call
// Snapshot.restore, we don't want to restore the scroll position. Instead we want the
// selected line (if any) to scroll into view.
if (event.type !== 'popstate') {
initialScrollPosition = null
}
})
function viewModeURL(viewMode: ViewMode) {
switch (viewMode) {
@ -127,7 +153,7 @@
</FileHeader>
{/if}
{#if !blobPending && blob && !blob.binary && !data.compare}
{#if blob && !blob.binary && !data.compare}
<div class="file-info">
<FileViewModeSwitcher
aria-label="View mode"
@ -149,7 +175,7 @@
</div>
{/if}
<div class="content" class:loading={blobPending} class:compare={!!data.compare} class:fileNotFound>
<div class="content" class:loading={$combinedBlobData.blobPending} class:compare={!!data.compare} class:fileNotFound>
{#if !$combinedBlobData.highlightsPending && $combinedBlobData.highlightsError}
<Alert variant="danger">
Unable to load syntax highlighting: {$combinedBlobData.highlightsError.message}
@ -165,23 +191,32 @@
Unable to load iff
{/if}
{/await}
{:else if $combinedBlobData.blob && showFormatted}
<div class={`rich ${markdownStyles.markdown}`}>
{@html $combinedBlobData.blob.richHTML}
</div>
{:else if blob}
{#if blob.richHTML && !showRaw && !showBlame}
<div class={`rich ${markdownStyles.markdown}`}>
{@html blob.richHTML}
</div>
{:else}
<!--
This ensures that a new CodeMirror instance is created when the file changes.
This makes the CodeMirror behavior more predictable and avoids issues with
carrying over state from the previous file.
Specifically this will make it so that the scroll position is reset to
`initialScrollPosition` when the file changes.
-->
{#key blob.canonicalURL}
<CodeMirrorBlob
bind:this={cmblob}
{initialScrollPosition}
blobInfo={{
...blob,
revision: revision ?? '',
repoName,
commitID,
repoName: repoName,
revision: revision ?? '',
filePath,
}}
{highlights}
{showBlame}
blameData={$blameData}
{highlights}
wrapLines={$lineWrap}
selectedLines={selectedPosition?.line ? selectedPosition : null}
on:selectline={({ detail: range }) => {
@ -193,18 +228,16 @@
}}
{codeIntelAPI}
/>
{/if}
{:else if !blobPending}
{#if fileLoadingError}
<Alert variant="danger">
Unable to load file data: {fileLoadingError.message}
</Alert>
{:else if fileNotFound}
<div class="circle">
<Icon svgPath={mdiMapSearch} size={80} />
</div>
<h2>File not found</h2>
{/if}
{/key}
{:else if fileLoadingError}
<Alert variant="danger">
Unable to load file data: {fileLoadingError.message}
</Alert>
{:else if fileNotFound}
<div class="circle">
<Icon svgPath={mdiMapSearch} size={80} />
</div>
<h2>File not found</h2>
{/if}
</div>
@ -212,7 +245,7 @@
.content {
display: flex;
flex-direction: column;
overflow-x: auto;
overflow: auto;
flex: 1;
&.compare {
@ -240,7 +273,6 @@
.rich {
padding: 1rem;
overflow: auto;
max-width: 50rem;
}

View File

@ -27,6 +27,12 @@ test.beforeEach(({ sg }) => {
{
canonicalURL: `/${repoName}/-/blob/src/index.js`,
},
{
canonicalURL: `/${repoName}/-/blob/src/large-file-1.js`,
},
{
canonicalURL: `/${repoName}/-/blob/src/large-file-2.js`,
},
],
},
{
@ -39,6 +45,28 @@ test.beforeEach(({ sg }) => {
richHTML: '',
content: '"file content"',
},
{
__typename: 'GitBlob',
name: 'large-file-1.js',
path: 'src/large-file-1.js',
canonicalURL: `/${repoName}/-/blob/src/large-file-1.js`,
isDirectory: false,
languages: ['JavaScript'],
richHTML: '',
content: Array.from({ length: 500 }, (_, i) => `// line ${i + 1};`).join('\n'),
totalLines: 500,
},
{
__typename: 'GitBlob',
name: 'large-file-2.js',
path: 'src/large-file-2.js',
canonicalURL: `/${repoName}/-/blob/src/large-file-2.js`,
isDirectory: false,
languages: ['JavaScript'],
richHTML: '',
content: Array.from({ length: 300 }, (_, i) => `// line ${i + 1};`).join('\n'),
totalLines: 300,
},
])
sg.mockOperations({
@ -50,7 +78,7 @@ test.beforeEach(({ sg }) => {
},
},
}),
TreeEntries: ({}) => ({
TreeEntries: ({ repoName }) => ({
repository: {
commit: {
tree: {
@ -59,11 +87,11 @@ test.beforeEach(({ sg }) => {
},
},
}),
BlobPageQuery: ({}) => ({
BlobPageQuery: ({ path, repoName }) => ({
repository: {
commit: {
blob: {
canonicalURL: `/${repoName}/-/blob/src/index.js`,
canonicalURL: `/${repoName}/-/blob/${path}`,
},
},
},
@ -215,6 +243,108 @@ test.describe('file header', () => {
})
})
test.describe('scroll behavior', () => {
const url = `/${repoName}/-/blob/src/large-file-1.js`
test('initial page load', async ({ page }) => {
await page.goto(url)
await expect(page.getByText('line 1;'), 'file is scrolled to the top').toBeVisible()
})
test('initial page load with selected line', async ({ page }) => {
await page.goto(url + '?L100')
const selectedLine = page.getByTestId('selected-line')
await expect(selectedLine, 'selected line is scrolled into view').toBeVisible()
await expect(selectedLine).toHaveText(/line 100;/)
})
test('go to another file', async ({ page, utils }) => {
await page.goto(url)
// Scroll to some arbitrary position
await utils.scrollYAt(page.getByText('line 1;'), 1000)
await page.getByRole('link', { name: 'large-file-2.js' }).click()
await expect(page.getByText('line 1;')).toBeVisible()
})
test('select a line', async ({ page, utils }) => {
await page.goto(url)
// Scrolls to line 64 at the top (found out by inspecting the test)
await utils.scrollYAt(page.getByText('line 1;'), 1000)
const line64 = page.getByText('line 64;')
await expect(line64).toBeVisible()
const position = await line64.boundingBox()
// Select line
await page.getByText('80', { exact: true }).click()
await expect(page.getByTestId('selected-line')).toHaveText(/line 80;/)
// Compare positions
expect((await line64.boundingBox())?.y, 'selecting a line preserves scroll position').toBe(position?.y)
})
test('[back] preserve scroll position', async ({ page, utils }) => {
await page.goto(url)
const line1 = page.getByText('line 1;')
await expect(line1).toBeVisible()
// Scrolls to line 64 at the top (found out by inspecting the test)
await utils.scrollYAt(line1, 1000)
const line64 = page.getByText('line 64;')
await expect(line64).toBeVisible()
const position = await line64.boundingBox()
await page.getByRole('link', { name: 'large-file-2.js' }).click()
await expect(line1).toBeVisible()
await page.goBack()
expect((await page.getByText('line 64;').boundingBox())?.y, 'restores scroll position on back navigation').toBe(
position?.y
)
})
test('[forward] preserve scroll position', async ({ page, utils }) => {
await page.goto(url)
await page.getByRole('link', { name: 'large-file-2.js' }).click()
const firstLine = page.getByText('line 1;')
await expect(firstLine).toBeVisible()
// Scrolls to line 64 at the top (found out by inspecting the test)
await utils.scrollYAt(firstLine, 1000)
const line64 = page.getByText('line 64;')
await expect(line64).toBeVisible()
const position = await line64.boundingBox()
await page.goBack()
await expect(page.getByText('/ large-file-1.js')).toBeVisible()
await page.goForward()
await expect(page.getByText('/ large-file-2.js')).toBeVisible()
expect((await line64.boundingBox())?.y, 'restores scroll navigation on forward navigation').toBe(position?.y)
})
test('[back] preserve scroll position with selected line', async ({ page, utils }) => {
await page.goto(url + '?L100')
const line100 = page.getByText('line 100;')
await expect(line100).toBeVisible()
// Scrolls to line 210 at the top (found out by inspecting the test)
await utils.scrollYAt(line100, 2000)
const line210 = page.getByText('line 210;')
await expect(line210).toBeVisible()
const position = await line210.boundingBox()
await page.getByRole('link', { name: 'large-file-2.js' }).click()
await expect(page.getByText('line 1;')).toBeVisible()
// This should restore the previous scroll position, not go to the selected line
await page.goBack()
expect((await line210.boundingBox())?.y, 'restores scroll position on back navigation').toBe(position?.y)
})
})
test('non-existent file', async ({ page, sg }) => {
sg.mockOperations({
BlobPageQuery: ({}) => ({

View File

@ -82,12 +82,13 @@
<style lang="scss">
.content {
flex: 1;
overflow: auto;
}
.header {
background-color: var(--body-bg);
position: sticky;
top: 2.8rem;
top: 0;
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin: 0;

View File

@ -50,6 +50,15 @@ export const selectedLines = StateField.define<SelectedLineRange>({
create() {
return null
},
compare(a, b) {
if (a === b) {
return true
}
if (!a || !b) {
return false
}
return a.line === b.line && a.endLine === b.endLine
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setSelectedLines)) {
@ -219,48 +228,48 @@ export const lineScrollEnforcing = Annotation.define<'scroll-enforcing'>()
* View plugin responsible for scrolling the selected line(s) into view if/when
* necessary.
*/
const scrollIntoView = ViewPlugin.fromClass(
class implements PluginValue {
private lastSelectedLines: SelectedLineRange | null = null
constructor(private readonly view: EditorView) {
this.lastSelectedLines = this.view.state.field(selectedLines)
class ScrollIntoView implements PluginValue {
private lastSelectedLines: SelectedLineRange | null = null
constructor(private readonly view: EditorView, config: SelectableLineNumbersConfig) {
this.lastSelectedLines = this.view.state.field(selectedLines)
if (!config.skipInitialScrollIntoView) {
this.scrollIntoView(this.lastSelectedLines)
}
}
public update(update: ViewUpdate): void {
const currentSelectedLines = update.state.field(selectedLines)
const isForcedScroll = update.transactions.some(
transaction => transaction.annotation(lineScrollEnforcing) === 'scroll-enforcing'
)
public update(update: ViewUpdate): void {
const currentSelectedLines = update.state.field(selectedLines)
const isForcedScroll = update.transactions.some(
transaction => transaction.annotation(lineScrollEnforcing) === 'scroll-enforcing'
)
const hasSelectedLineChanged = isForcedScroll ? true : this.lastSelectedLines !== currentSelectedLines
const isExternalTrigger = update.transactions.some(
transaction => transaction.annotation(lineSelectionSource) !== 'gutter'
)
const hasSelectedLineChanged = isForcedScroll ? true : this.lastSelectedLines !== currentSelectedLines
const isExternalTrigger = update.transactions.some(
transaction => transaction.annotation(lineSelectionSource) !== 'gutter'
)
if (hasSelectedLineChanged && isExternalTrigger) {
// Only scroll selected lines into view when the user isn't
// currently selecting lines themselves (as indicated by the
// presence of the "gutter" annotation). Otherwise, the scroll
// position might change while the user is selecting lines.
this.lastSelectedLines = currentSelectedLines
this.scrollIntoView(currentSelectedLines)
}
}
public scrollIntoView(selection: SelectedLineRange): void {
if (selection && shouldScrollIntoView(this.view, selection)) {
window.requestAnimationFrame(() => {
this.view.dispatch({
effects: EditorView.scrollIntoView(this.view.state.doc.line(selection.line).from, {
y: 'center',
}),
})
})
}
if (hasSelectedLineChanged && isExternalTrigger) {
// Only scroll selected lines into view when the user isn't
// currently selecting lines themselves (as indicated by the
// presence of the "gutter" annotation). Otherwise, the scroll
// position might change while the user is selecting lines.
this.lastSelectedLines = currentSelectedLines
this.scrollIntoView(currentSelectedLines)
}
}
)
public scrollIntoView(selection: SelectedLineRange): void {
if (selection && shouldScrollIntoView(this.view, selection)) {
window.requestAnimationFrame(() => {
this.view.dispatch({
effects: EditorView.scrollIntoView(this.view.state.doc.line(selection.line).from, {
y: 'center',
}),
})
})
}
}
}
const selectedLineNumberTheme = EditorView.theme({
'.cm-lineNumbers': {
@ -282,6 +291,12 @@ interface SelectableLineNumbersConfig {
* In this case `onSelection` will be ignored.
*/
onLineClick?: (line: number) => void
/**
* If set to true, the initial selection will not be scrolled into view.
*/
skipInitialScrollIntoView?: boolean
// todo(fkling): Refactor this logic, maybe move into separate extensions
}
@ -300,7 +315,7 @@ export function selectableLineNumbers(config: SelectableLineNumbersConfig): Exte
let dragging = false
return [
scrollIntoView,
ViewPlugin.define(view => new ScrollIntoView(view, config)),
selectedLines.init(() => config.initialSelection),
lineNumbers({
domEventHandlers: {

View File

@ -280,7 +280,7 @@
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.23.1",
"@codemirror/view": "^6.26.3",
"@date-fns/utc": "^1.1.1",
"@graphiql/react": "^0.10.0",
"@lezer/common": "^1.0.0",

View File

@ -21,7 +21,7 @@ importers:
version: 3.8.0-alpha.7(graphql-ws@5.15.0)(graphql@15.4.0)(react-dom@18.1.0)(react@18.1.0)
'@codemirror/autocomplete':
specifier: ^6.1.0
version: 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.0.0)
version: 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(@lezer/common@1.0.0)
'@codemirror/commands':
specifier: ^6.0.1
version: 6.0.1
@ -47,8 +47,8 @@ importers:
specifier: ^6.4.0
version: 6.4.0
'@codemirror/view':
specifier: ^6.23.1
version: 6.23.1
specifier: ^6.26.3
version: 6.26.3
'@date-fns/utc':
specifier: ^1.1.1
version: 1.1.1
@ -75,7 +75,7 @@ importers:
version: 0.0.1
'@opencodegraph/codemirror-extension':
specifier: ^0.0.1
version: 0.0.1(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(react-dom@18.1.0)(react@18.1.0)
version: 0.0.1(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(react-dom@18.1.0)(react@18.1.0)
'@opentelemetry/api':
specifier: ^1.4.0
version: 1.4.0
@ -3241,7 +3241,7 @@ packages:
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
dev: true
/@codemirror/autocomplete@6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.0.0):
/@codemirror/autocomplete@6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(@lezer/common@1.0.0):
resolution: {integrity: sha512-wtO4O5WDyXhhCd4q4utDIDZxnQfmJ++3dGBCG9LMtI79+92OcA1DVk/n7BEupKmjIr8AzvptDz7YQ9ud6OkU+A==}
peerDependencies:
'@codemirror/language': ^6.0.0
@ -3251,7 +3251,7 @@ packages:
dependencies:
'@codemirror/language': 6.2.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@lezer/common': 1.0.0
dev: false
@ -3260,14 +3260,14 @@ packages:
dependencies:
'@codemirror/language': 6.2.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@lezer/common': 1.0.0
dev: false
/@codemirror/lang-css@6.0.0(@codemirror/view@6.23.1)(@lezer/common@1.0.0):
/@codemirror/lang-css@6.0.0(@codemirror/view@6.26.3)(@lezer/common@1.0.0):
resolution: {integrity: sha512-jBqc+BTuwhNOTlrimFghLlSrN6iFuE44HULKWoR4qKYObhOIl9Lci1iYj6zMIte1XTQmZguNvjXMyr43LUKwSw==}
dependencies:
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.0.0)
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(@lezer/common@1.0.0)
'@codemirror/language': 6.2.0
'@codemirror/state': 6.4.0
'@lezer/css': 1.0.0
@ -3276,11 +3276,11 @@ packages:
- '@lezer/common'
dev: false
/@codemirror/lang-html@6.1.0(@codemirror/view@6.23.1):
/@codemirror/lang-html@6.1.0(@codemirror/view@6.26.3):
resolution: {integrity: sha512-gA7NmJxqvnhwza05CvR7W/39Ap9r/4Vs9uiC0IeFYo1hSlJzc/8N6Evviz6vTW1x8SpHcRYyqKOf6rpl6LfWtg==}
dependencies:
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.0.0)
'@codemirror/lang-css': 6.0.0(@codemirror/view@6.23.1)(@lezer/common@1.0.0)
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(@lezer/common@1.0.0)
'@codemirror/lang-css': 6.0.0(@codemirror/view@6.26.3)(@lezer/common@1.0.0)
'@codemirror/lang-javascript': 6.0.1
'@codemirror/language': 6.2.0
'@codemirror/state': 6.4.0
@ -3293,11 +3293,11 @@ packages:
/@codemirror/lang-javascript@6.0.1:
resolution: {integrity: sha512-kjGbBEosl+ozDU5ruDV48w4v3H6KECTFiDjqMLT0KhVwESPfv3wOvnDrTT0uaMOg3YRGnBWsyiIoKHl/tNWWDg==}
dependencies:
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.0.0)
'@codemirror/autocomplete': 6.1.0(@codemirror/language@6.2.0)(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(@lezer/common@1.0.0)
'@codemirror/language': 6.2.0
'@codemirror/lint': 6.0.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@lezer/common': 1.0.0
'@lezer/javascript': 1.0.1
dev: false
@ -3312,10 +3312,10 @@ packages:
/@codemirror/lang-markdown@6.0.0:
resolution: {integrity: sha512-ozJaO1W4WgGlwWOoYCSYzbVhhM0YM/4lAWLrNsBbmhh5Ztpl0qm4CgEQRl3t8/YcylTZYBIXiskui8sHNGd4dg==}
dependencies:
'@codemirror/lang-html': 6.1.0(@codemirror/view@6.23.1)
'@codemirror/lang-html': 6.1.0(@codemirror/view@6.26.3)
'@codemirror/language': 6.2.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@lezer/common': 1.0.0
'@lezer/markdown': 1.0.1
dev: false
@ -3324,7 +3324,7 @@ packages:
resolution: {integrity: sha512-tabB0Ef/BflwoEmTB4a//WZ9P90UQyne9qWB9YFsmeS4bnEqSys7UpGk/da1URMXhyfuzWCwp+AQNMhvu8SfnA==}
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@lezer/common': 1.0.0
'@lezer/highlight': 1.0.0
'@lezer/lr': 1.2.0
@ -3341,7 +3341,7 @@ packages:
resolution: {integrity: sha512-nUUXcJW1Xp54kNs+a1ToPLK8MadO0rMTnJB8Zk4Z8gBdrN0kqV7uvUraU/T2yqg+grDNR38Vmy/MrhQN/RgwiA==}
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
crelt: 1.0.5
dev: false
@ -3349,7 +3349,7 @@ packages:
resolution: {integrity: sha512-uOinkOrM+daMduCgMPomDfKLr7drGHB4jHl3Vq6xY2WRlL7MkNsBE0b+XHYa/Mee2npsJOgwvkW4n1lMFeBW2Q==}
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
crelt: 1.0.5
dev: false
@ -3357,8 +3357,8 @@ packages:
resolution: {integrity: sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==}
dev: false
/@codemirror/view@6.23.1:
resolution: {integrity: sha512-J2Xnn5lFYT1ZN/5ewEoMBCmLlL71lZ3mBdb7cUEuHhX2ESoSrNEucpsDXpX22EuTGm9LOgC9v4Z0wx+Ez8QmGA==}
/@codemirror/view@6.26.3:
resolution: {integrity: sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==}
dependencies:
'@codemirror/state': 6.4.0
style-mod: 4.1.0
@ -6159,14 +6159,14 @@ packages:
rxjs: 7.8.1
dev: false
/@opencodegraph/codemirror-extension@0.0.1(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(react-dom@18.1.0)(react@18.1.0):
/@opencodegraph/codemirror-extension@0.0.1(@codemirror/state@6.4.0)(@codemirror/view@6.26.3)(react-dom@18.1.0)(react@18.1.0):
resolution: {integrity: sha512-n3WP/88qcgeJboqGI7HOfPujkhnTAJ5TGV9IIs03MAyY/V4/+/J8TclhUzjCX5Pu27eoG4RuZlY7379ejUvYkw==}
peerDependencies:
'@codemirror/state': ^6.2.0
'@codemirror/view': ^6.7.2
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.1
'@codemirror/view': 6.26.3
'@opencodegraph/client': 0.0.1
'@opencodegraph/ui-react': 0.0.1(react-dom@18.1.0)(react@18.1.0)
deep-equal: 2.2.3