From 870c28aa6550a8422517f97bcf54a80abb5a14ab Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Tue, 6 Oct 2026 22:54:28 +0800 Subject: [PATCH 1/5] refactor(viewer): share list tree controls with Interactive cells --- src/listViewer.ts | 229 +++++++++++++++++++++++++++++++++++----------- src/session.ts | 92 ++----------------- 2 files changed, 184 insertions(+), 137 deletions(-) diff --git a/src/listViewer.ts b/src/listViewer.ts index a1aaa09e8..c450c0740 100644 --- a/src/listViewer.ts +++ b/src/listViewer.ts @@ -1,3 +1,5 @@ +/// + export interface ListViewNavigation { title: string; path: number[]; @@ -6,42 +8,55 @@ export interface ListViewNavigation { vector?: boolean; } -/** Script shared by the list webview and its interaction tests. */ -export function getListViewerScript(documentGeneration: number, initial: ListViewNavigation = { - title: '', path: [], breadcrumbs: [{ label: '', path: [] }], -}): string { - return ` - const vscode = acquireVsCodeApi(); - const documentGeneration = ${documentGeneration}; - const pending = new Map(); - let nextRequestId = 0; - const list = document.getElementById('list'); - const back = document.getElementById('back'); - const breadcrumbs = document.getElementById('breadcrumbs'); - const navigationStatus = document.getElementById('navigation-status'); - const pages = new Map(); - const history = []; - let current; - let navigating = false; +export interface ListViewPage { + children: { label: string; str: string; index: number; viewable?: boolean; has_children?: boolean }[]; + next_start?: number | null; +} +export interface ListViewReply extends Partial { + requestId: number; + error?: string; + navigation?: ListViewNavigation; +} +export interface ListViewer { + reply(message: ListViewReply): void; + dispose(): void; + navigation(): ListViewNavigation; +} - function showNavigation(navigation, goingBack = false) { +/** Shared tree, paging and navigation for the panel and Interactive cells. */ +export function createListViewer( + { root, list, back, breadcrumbs, navigationStatus }: { + root: HTMLElement; list: HTMLElement; back: HTMLButtonElement; breadcrumbs: HTMLElement; navigationStatus: HTMLElement; + }, + initial: ListViewNavigation, + request: (message: { message: string; path: number[]; index?: number; start?: number }) => number, + initialPage?: ListViewPage, + live = true, +): ListViewer { + const pending = new Map void>(); + const pages = new Map(); + const history: ListViewNavigation[] = []; + let current: ListViewNavigation; + let navigating = false; + let disposed = false; + function showNavigation(navigation: ListViewNavigation, goingBack = false): void { const key = JSON.stringify(navigation.path); if (current) { - pages.get(JSON.stringify(current.path)).scrollTop = list.scrollTop; - if (goingBack) history.pop(); - else if (JSON.stringify(current.path) !== key) history.push(current); + pages.get(JSON.stringify(current.path))!.scrollTop = list.scrollTop; + if (goingBack) { history.pop(); } + else if (JSON.stringify(current.path) !== key) { history.push(current); } } current = navigation; - document.body.classList.toggle('vector', !!navigation.vector); + root.classList.toggle('vector', !!navigation.vector); let page = pages.get(key); if (!page) { const element = document.createElement('div'); - page = { element, scrollTop: 0, loader: createPage(element, navigation.path, navigation.vector) }; + page = { element, scrollTop: 0, loader: createPage(element, navigation.path, navigation.vector, pages.size === 0 ? initialPage : undefined) }; pages.set(key, page); } list.replaceChildren(page.element); list.scrollTop = page.scrollTop; - back.disabled = history.length === 0; + back.disabled = !live || history.length === 0; breadcrumbs.replaceChildren(); navigation.breadcrumbs.forEach((crumb, index) => { if (index) { @@ -55,18 +70,21 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie item.className = 'breadcrumb'; item.textContent = crumb.label; item.title = crumb.label; - if (isCurrent) item.setAttribute('aria-current', 'page'); - else item.addEventListener('click', () => navigate('listview/navigate', { path: crumb.path })); + if (isCurrent) { item.setAttribute('aria-current', 'page'); } + else { + (item as HTMLButtonElement).disabled = !live; + item.addEventListener('click', () => navigate('listview/navigate', { path: crumb.path })); + } breadcrumbs.appendChild(item); }); page.loader.loadOnce(); } - function navigate(message, params, goingBack = false) { - if (navigating) return; + function navigate(message: string, params: { path: number[]; index?: number }, goingBack = false): void { + if (disposed || !live || navigating) { return; } navigating = true; navigationStatus.textContent = ''; - const requestId = ++nextRequestId; + const requestId = request({ message, ...params }); pending.set(requestId, (response) => { navigating = false; if (response.error) { @@ -75,14 +93,13 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie showNavigation(response.navigation, goingBack); } }); - vscode.postMessage({ message, documentGeneration, requestId, ...params }); } back.addEventListener('click', () => { - if (history.length) navigate('listview/navigate', { path: history[history.length - 1].path }, true); + if (history.length) { navigate('listview/navigate', { path: history[history.length - 1].path }, true); } }); - function createPage(container, path, vector = false) { + function createPage(container: HTMLElement, path: number[], vector = false, savedPage?: ListViewPage): { loadOnce(): void } { const rows = document.createElement('div'); const more = document.createElement('button'); more.className = 'load-more'; @@ -92,27 +109,26 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie container.appendChild(rows); container.appendChild(more); container.appendChild(status); - let nextStart = 1; + let nextStart: number | null = 1; let loading = false; let loaded = false; - function loadPage() { - if (loading || nextStart === null) return; + function loadPage(): void { + if (disposed || (!live && !savedPage) || loading || nextStart === null) { return; } loading = true; more.disabled = true; more.textContent = 'Loading…'; status.textContent = ''; - const requestId = ++nextRequestId; - pending.set(requestId, (message) => { + const receive = (message: ListViewReply): void => { loading = false; - more.disabled = false; + more.disabled = !live; if (message.error) { status.textContent = message.error; more.textContent = 'Retry'; return; } loaded = true; - for (const item of message.children) { + for (const item of message.children ?? []) { const expandable = item.has_children; const entry = document.createElement(expandable ? 'details' : 'div'); const row = document.createElement(expandable ? 'summary' : 'div'); @@ -135,6 +151,7 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie if (item.viewable) { const button = document.createElement('button'); button.title = 'View'; + button.disabled = !live; button.setAttribute('aria-label', 'View ' + item.label); const icon = document.createElement('span'); icon.className = 'codicon codicon-open-preview'; @@ -152,9 +169,10 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie const children = document.createElement('div'); children.className = 'children'; entry.appendChild(children); - let page; + let page: { loadOnce(): void } | undefined; entry.addEventListener('toggle', () => { - if (entry.open) { + if ((entry as HTMLDetailsElement).open && !live && !page) { (entry as HTMLDetailsElement).open = false; return; } + if ((entry as HTMLDetailsElement).open) { page ??= createPage(children, [...path, item.index]); page.loadOnce(); } @@ -166,22 +184,129 @@ export function getListViewerScript(documentGeneration: number, initial: ListVie more.hidden = nextStart === null; more.textContent = 'Load more'; status.textContent = rows.childElementCount ? '' : 'No items'; - }); - vscode.postMessage({ message: 'listview/page', documentGeneration, requestId, path, start: nextStart }); + }; + if (savedPage) { + const page = savedPage; savedPage = undefined; receive({ ...page, requestId: 0 }); + } else { + pending.set(request({ message: 'listview/page', path, start: nextStart }), receive); + } } more.addEventListener('click', loadPage); - return { loadOnce: () => { if (!loaded) loadPage(); } }; + return { loadOnce: () => { if (!loaded) { loadPage(); } } }; } - window.addEventListener('message', (event) => { + showNavigation(initial); + return { + navigation: () => current, + reply(message) { + const receive = pending.get(message.requestId); + if (receive) { pending.delete(message.requestId); receive(message); } + }, + dispose() { disposed = true; pending.clear(); }, + }; +} + +/** Script shared by the list webview and its interaction tests. */ +export function getListViewerScript(documentGeneration: number, initial: ListViewNavigation = { + title: '', path: [], breadcrumbs: [{ label: '', path: [] }], +}): string { + return ` + const vscode = acquireVsCodeApi(); + const documentGeneration = ${documentGeneration}; + let nextRequestId = 0; + const viewer = (${createListViewer.toString()})({ + root: document.body, list: document.getElementById('list'), back: document.getElementById('back'), + breadcrumbs: document.getElementById('breadcrumbs'), navigationStatus: document.getElementById('navigation-status'), + }, ${JSON.stringify(initial).replace(/ { + const requestId = ++nextRequestId; + vscode.postMessage({ ...message, documentGeneration, requestId }); + return requestId; + }); + window.addEventListener('message', event => { const message = event.data; - if (!['listview/page', 'listview/navigation'].includes(message.message) || message.documentGeneration !== documentGeneration) return; - const receive = pending.get(message.requestId); - if (receive) { - pending.delete(message.requestId); - receive(message); - } + if (['listview/page', 'listview/navigation'].includes(message.message) && message.documentGeneration === documentGeneration) viewer.reply(message); }); - showNavigation(${JSON.stringify(initial).replace(/ .arrow { + color: var(--vscode-icon-foreground, currentColor); + } + .r-list-viewer details[open] > summary > .arrow { transform: rotate(90deg); } + .r-list-viewer .children { margin-left: 24px; } + .r-list-viewer button:focus-visible, .r-list-viewer summary:focus-visible { outline: 1px solid var(--vscode-focusBorder); } + .r-list-viewer .label { + min-width: 140px; + color: var(--vscode-symbolIcon-fieldForeground); + white-space: nowrap; + } + .r-list-viewer.vector .label { + min-width: 64px; + } + .r-list-viewer.vector .item { + gap: 8px; + } + .r-list-viewer .str { + flex: 1; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + } + .r-list-viewer button { + display: flex; + align-items: center; + border: 0; + padding: 2px; + color: var(--vscode-foreground); + background: transparent; + cursor: pointer; + font: inherit; + } + .r-list-viewer button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + } + .r-list-viewer .load-more { + margin: 8px; + } + .r-list-viewer .load-more[hidden] { + display: none; + } +`; diff --git a/src/session.ts b/src/session.ts index 35403d329..ba9061849 100644 --- a/src/session.ts +++ b/src/session.ts @@ -19,7 +19,7 @@ import { resolveBackend, jgdEnabled, CommonPlotManager } from './plotViewer'; import type { RSessionConnectionInfo } from './api'; import { showWebView } from './webViewer'; -import { getListViewerScript, ListViewNavigation } from './listViewer'; +import { getListViewerScript, listViewerStyle, ListViewNavigation } from './listViewer'; import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer'; import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; @@ -1947,94 +1947,16 @@ export function getListHtml( font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); } - #list { flex: 1; min-height: 0; overflow: auto; } - .navigation { - display: flex; - align-items: center; - gap: 12px; - padding: 6px 8px; - min-height: 28px; - border-bottom: 1px solid var(--vscode-panel-border); - background: var(--vscode-breadcrumb-background, var(--vscode-editor-background)); - } - #back { gap: 4px; padding: 4px 6px; flex-shrink: 0; border-radius: 3px; } - #back:disabled { opacity: 0.4; cursor: default; background: transparent; } - .codicon { flex-shrink: 0; } - #breadcrumbs { - display: flex; - align-items: center; - gap: 2px; - overflow-x: auto; - color: var(--vscode-breadcrumb-foreground); - } - .breadcrumb { padding: 4px; white-space: nowrap; border-radius: 3px; } - button.breadcrumb:hover { color: var(--vscode-breadcrumb-focusForeground); } - .breadcrumb[aria-current] { color: var(--vscode-breadcrumb-activeSelectionForeground); } - #navigation-status { padding: 0 8px; color: var(--vscode-errorForeground); } - .item { - display: flex; - align-items: center; - gap: 12px; - min-height: 28px; - padding: 2px 8px; - } - .item:hover { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-list-hoverForeground); - } - summary.item { cursor: pointer; list-style: none; } - summary.item::-webkit-details-marker { display: none; } - .arrow { width: 16px; height: 16px; flex-shrink: 0; } - summary > .arrow { - color: var(--vscode-icon-foreground, currentColor); - } - details[open] > summary > .arrow { transform: rotate(90deg); } - .children { margin-left: 24px; } - button:focus-visible, summary:focus-visible { outline: 1px solid var(--vscode-focusBorder); } - .label { - min-width: 140px; - color: var(--vscode-symbolIcon-fieldForeground); - white-space: nowrap; - } - body.vector .label { - min-width: 64px; - } - body.vector .item { - gap: 8px; - } - .str { - flex: 1; - color: var(--vscode-descriptionForeground); - white-space: pre-wrap; - } - button { - display: flex; - align-items: center; - border: 0; - padding: 2px; - color: var(--vscode-foreground); - background: transparent; - cursor: pointer; - font: inherit; - } - button:hover { - background-color: var(--vscode-toolbar-hoverBackground); - } - .load-more { - margin: 8px; - } - .load-more[hidden] { - display: none; - } + ${listViewerStyle} - + - -
+ +