diff --git a/sess/R/handlers.R b/sess/R/handlers.R index bc96216ed..ad0f10f50 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -374,8 +374,17 @@ listview_summary <- function(object) { } else if (size > 1L) { paste0(" [1:", size, "]") } - paste0(type, shape, " ", - listview_format_values(object[1L]), if (size > 1L) " ...") + numeric_preview <- is.numeric(object) && !is.object(object) + preview_size <- if (numeric_preview) min(size, 5L) else 1L + # Subset before formatting so even very large vectors have a bounded preview. + values <- object[seq_len(preview_size)] + formatted <- if (numeric_preview) { + vapply(values, format, "", digits = 3L, trim = TRUE, justify = "none") + } else { + listview_format_values(values) + } + paste0(type, shape, " ", paste(formatted, collapse = " "), + if (size > preview_size) " ...") } else { trimws(try_capture_str(object)) } diff --git a/sess/R/interactive.R b/sess/R/interactive.R index b6c1ce5af..03e935025 100644 --- a/sess/R/interactive.R +++ b/sess/R/interactive.R @@ -401,6 +401,17 @@ interactive_execute <- function(id, code, source = NULL) { printed)) return(TRUE) } + if (is.list(value) && !is.object(value)) { + view_id <- dataview_new_id() + # Nested panels reuse the root's owner; separate cell results must stay independent. + root <- listview_state(value, "List", paste0("interactive:", view_id)) + dataview_set_state(view_id, root) + preview <- get_workspace_children(view_id = view_id) + .interactive_event("display", c(list(kind = "list", viewId = view_id, + navigation = listview_navigation(listview_location(root))), + preview, .interactive_table_text(value))) + return(TRUE) + } if (inherits(value, "htmlwidget") && requireNamespace("htmlwidgets", quietly = TRUE)) { directory <- tempfile("widget-") dir.create(directory) diff --git a/sess/inst/tinytest/test-listview-format.R b/sess/inst/tinytest/test-listview-format.R index 56f257c18..d3a888359 100644 --- a/sess/inst/tinytest/test-listview-format.R +++ b/sess/inst/tinytest/test-listview-format.R @@ -1,4 +1,4 @@ -# List rows use the same class formatting and numeric precision as table cells. +# Individual vector rows retain table precision; numeric summaries are compact. local({ runtime <- sess:::.sess_env previous <- runtime$dataviews @@ -19,7 +19,9 @@ local({ "0", "NA", "NaN", "Inf", "-Inf") expect_equal(text(page(numbers)), expected) expect_equal(text(page(list(nested = numbers), list(1L))), expected) - expect_equal(text(page(as.list(numbers))), paste("num", expected)) + compact <- c("1.23", "1.54e-100", "-6.65e-13", "1.23e+14", + "0", "NA", "NaN", "Inf", "-Inf") + expect_equal(text(page(as.list(numbers))), paste("num", compact)) for (index in seq_along(numbers)) { expect_true(sess:::listview_supported(numbers[index])) expect_true(sess:::listview_is_vector(numbers[index])) @@ -27,9 +29,29 @@ local({ expect_false(page(numbers[index])$children[[1L]]$has_children) expect_false(page(numbers[index])$children[[1L]]$viewable) } - expect_true(grepl(expected[[1L]], text(page(list(numbers)))[[1L]], fixed = TRUE)) + expect_equal(text(page(list(numbers))), + "num [1:9] 1.23 1.54e-100 -6.65e-13 1.23e+14 0 ...") expect_equal(text(page(list(matrix(rep(numbers[[1L]], 4L), nrow = 2L)))), - paste0("num [1:2, 1:2] ", expected[[1L]], " ...")) + "num [1:2, 1:2] 1.23 1.23 1.23 1.23") + values <- c(-1.33067613368562, -0.0827652250416069, 0.836484327361507, + 1.23456789, -2.3456789) + root <- list(a = 1, b = rep(values, 2L), c = list(x = values, y = list(z = values[1:3])), + mtcars = datasets::mtcars) + expect_equal(text(page(root))[1:3], + c("num 1", "num [1:10] -1.33 -0.0828 0.836 1.23 -2.35 ...", "List of 2")) + expect_equal(text(page(root, list(3L)))[[1L]], + "num [1:5] -1.33 -0.0828 0.836 1.23 -2.35") + expect_equal(text(page(root, list(3L, 2L))), "num [1:3] -1.33 -0.0828 0.836") + expect_equal(text(page(list(integer(), numeric(), 1:6))), + c("int(0)", "num(0)", "int [1:6] 1 2 3 4 5 ...")) + expect_equal(text(page(list(c(0, NA_real_, NaN, Inf, -Inf)))), + "num [1:5] 0 NA NaN Inf -Inf") + # A summary reads only its prefix, including for compact ALTREP sequences. + expect_equal(text(page(list(seq_len(1000000000L)))), + "int [1:1000000000] 1 2 3 4 5 ...") + options(digits = 22L) + expect_equal(text(page(list(values))), "num [1:5] -1.33 -0.0828 0.836 1.23 -2.35") + options(digits = 3L) expect_identical(numbers, c( 1.23456789012345, 1.54e-100, -6.65e-13, 1.23456789012345e14, 0, NA_real_, NaN, Inf, -Inf )) diff --git a/src/interactive/manager.ts b/src/interactive/manager.ts index ba95239c5..1f204b350 100644 --- a/src/interactive/manager.ts +++ b/src/interactive/manager.ts @@ -68,7 +68,7 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private closed = false; private clientId: string; private savedConnections: Map; - private outputViews: Map; + private outputViews: Map; private status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 20); constructor(private context: vscode.ExtensionContext) { @@ -1064,6 +1064,7 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro archived: cell.generation !== view.model.generation, connected: view.client.connected, running: !['exited', 'stopping'].includes(view.client.manifest.status) }; const preference = this.outputViews.get(`${view.client.manifest.id}:${cell.generation}:${String(data.displayId)}`); + if (data.kind === 'list') { display.listView = preference?.listView ?? 'list'; } if (data.kind === 'table') { display.tableView = preference?.tableView ?? util.config().get('interactive.tableView', 'table'); } if (data.svg || data.asset) { display.url = view.base + String(data.svg ?? data.asset).split('/').map(encodeURIComponent).join('/'); } else if (typeof data.url === 'string' && /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(data.url)) { @@ -1071,7 +1072,7 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro } if (data.kind === 'mime' && data.mime === 'text/html') { display.kind = 'htmlText'; } items = [vscode.NotebookCellOutputItem.json(display, DISPLAY_MIME), - vscode.NotebookCellOutputItem.text(data.kind === 'table' ? (typeof data.printedText === 'string' ? data.printedText : `${String(data.totalRows)} rows\n${JSON.stringify(data.rows, null, 2)}`) : `R ${String(data.kind)} output`)]; + vscode.NotebookCellOutputItem.text(typeof data.printedText === 'string' ? data.printedText : data.kind === 'table' ? `${String(data.totalRows)} rows\n${JSON.stringify(data.rows, null, 2)}` : data.kind === 'list' ? JSON.stringify(data.children, null, 2) : `R ${String(data.kind)} output`)]; if (typeof data.svg === 'string') { try { const svg = portable ? readAsset(path.join(this.root, view.client.manifest.id, 'assets'), data.svg).toString('base64') @@ -1185,19 +1186,20 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro const data = output.data; try { if ((message.action === 'tableView' && data.kind === 'table' && ['table', 'text'].includes(String(message.mode))) || + (message.action === 'listView' && data.kind === 'list' && ['list', 'text'].includes(String(message.mode))) || (message.action === 'plotPage' && ['plot', 'image'].includes(String(data.kind)) && owner?.outputs.some(item => item.type === 'display' && ['plot', 'image'].includes(String(item.data.kind)) && item.data.displayId === message.selectedPlot))) { const key = `${view.client.manifest.id}:${owner.generation}:${String(data.displayId)}`; - const preference = message.action === 'tableView' ? { tableView: String(message.mode) } : { selectedPlot: String(message.selectedPlot) }; + const preference = message.action === 'tableView' ? { tableView: String(message.mode) } : message.action === 'listView' ? { listView: String(message.mode) } : { selectedPlot: String(message.selectedPlot) }; this.outputViews.delete(key); this.outputViews.set(key, preference); while (this.outputViews.size > 1000) { this.outputViews.delete(this.outputViews.keys().next().value as string); } await this.context.workspaceState.update('r.interactive.outputViews', [...this.outputViews]); return; } - if (['table', 'page', 'resize'].includes(String(message.action)) && ['exited', 'stopping'].includes(view.client.manifest.status)) { + if (['table', 'page', 'list', 'listPage', 'listNavigate', 'listItem', 'resize'].includes(String(message.action)) && ['exited', 'stopping'].includes(view.client.manifest.status)) { throw new Error('R has stopped. Start a new session and run the code again to use live controls.'); } - if (['table', 'page', 'resize'].includes(String(message.action)) && (view.restarting || owner?.generation !== view.model.generation)) { + if (['table', 'page', 'list', 'listPage', 'listNavigate', 'listItem', 'resize'].includes(String(message.action)) && (view.restarting || owner?.generation !== view.model.generation)) { throw new Error('This output belongs to a previous R process. Run its code again to use live controls.'); } let result: unknown; @@ -1209,6 +1211,28 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (data.kind !== 'table') { return; } result = await queryTablePage(data, message, request => view.client.request('inspect', request)); break; } + case 'list': case 'listPage': case 'listNavigate': case 'listItem': { + if (data.kind !== 'list') { return; } + const listPath = message.path ?? []; + if (!Array.isArray(listPath) || !listPath.every(index => Number.isSafeInteger(index) && index > 0)) { throw new Error('Invalid list path'); } + const params = { view_id: data.viewId, path: listPath }; + if (message.action === 'listPage') { + if (!Number.isSafeInteger(message.start) || Number(message.start) < 1) { throw new Error('Invalid list page'); } + result = await view.client.request('inspect', { method: 'workspace_children', params: { ...params, start: message.start } }); + } else { + if (message.action === 'listItem' && (!Number.isSafeInteger(message.index) || Number(message.index) < 1)) { throw new Error('Invalid list index'); } + const navigation = await view.client.request('inspect', { + method: message.action === 'listItem' ? 'listview_view' : 'listview_navigate', params: { ...params, index: message.index }, + }); + if (!navigation) { throw new Error('Unable to open this item. Check the R session and try again.'); } + if (typeof navigation === 'object' && Array.isArray(navigation.breadcrumbs)) { + if (message.action === 'list') { + await session.showDataView('list', 'json', `${view.client.manifest.label}: ${navigation.title}`, '', 'Beside', String(data.viewId), navigation, view.target.sessionId); + } else { result = { navigation }; } + } + } + break; + } case 'resize': if (data.kind !== 'plot') { return; } await view.client.request('resize', { device: data.device, plot: data.plot, width: message.width, height: message.height }); break; @@ -1534,6 +1558,8 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro const columns = data.columns as { field: string; headerName: string; type?: unknown }[]; const rows = data.rows as Record[]; sections.push(`${columns.map(column => ``).join('')}${rows.map((row, rowIndex) => `${columns.map(column => ``).join('')}`).join('')}
${escapeXml(column.headerName)}
${escapeXml(tableDisplayValue(data, row, column.field, rowIndex))}

${tableSnapshotSummary(data) || `${String(data.totalRows)} rows (preview)`}

`); + } else if (data.kind === 'list') { + sections.push(`
${escapeXml(data.printedText ?? JSON.stringify(data.children, null, 2))}
`); } else if (data.kind === 'mime' && data.mime === 'text/html') { sections.push(``); } else { sections.push(`
${escapeXml(data.text ?? data.message ?? JSON.stringify(data, null, 2))}
`); } diff --git a/src/interactive/renderer.ts b/src/interactive/renderer.ts index 6ff7e6e5e..3ef312bc3 100644 --- a/src/interactive/renderer.ts +++ b/src/interactive/renderer.ts @@ -1,6 +1,7 @@ /// import { createInlineTable, InlineTable, InlineTableState, inlineTableStyle } from './inlineTable'; +import { createListViewer, ListViewer, ListViewNavigation, ListViewPage, ListViewReply, listViewerStyle } from '../listViewer'; import { toolbarButton as button, toolbarStyle } from './rendererToolbar'; interface OutputItem { id: string; json(): Record } @@ -12,7 +13,7 @@ interface RendererContext { export function activate(context: RendererContext): { renderOutputItem(item: OutputItem, element: HTMLElement): void; disposeOutputItem(id?: string): void } { interface OutputState { element: HTMLElement; data: Record; - table?: InlineTable; + table?: InlineTable; list?: ListViewer; saveRequest?: number; saveButton?: HTMLButtonElement; savePlot?(format: string): void; updatePlotPaging?(): void; dispose?(): void; } @@ -20,8 +21,8 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out const tables = new Map(); // Output IDs change on replaceOutput. Keep local choices until the host has // persisted them, and while browsing a saved notebook without a live host. - const choices = new Map(); - const remember = (key: string, value: { tableView?: string; selectedPlot?: string }): void => { + const choices = new Map(); + const remember = (key: string, value: { tableView?: string; listView?: string; selectedPlot?: string }): void => { choices.delete(key); choices.set(key, value); while (choices.size > 1000) { choices.delete(choices.keys().next().value as string); } }; @@ -38,6 +39,8 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out const status = output.element.querySelector('[data-status]'); if (message.action === 'page') { output.table?.reply(message); + } else if (['listPage', 'listNavigate', 'listItem'].includes(String(message.action))) { + output.list?.reply({ ...message.result as ListViewReply, requestId: Number(message.requestId), error: typeof message.error === 'string' ? message.error : undefined }); } else if (message.action === 'saveAs') { if (message.requestId !== output.saveRequest || output.saveRequest === undefined) { return; } output.saveRequest = undefined; @@ -63,7 +66,14 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out element.classList.add('r-interactive-output'); element.style.cssText = 'color:var(--vscode-editor-foreground);font-family:var(--vscode-font-family)'; const style = document.createElement('style'); - style.textContent = toolbarStyle + inlineTableStyle; + style.textContent = toolbarStyle + inlineTableStyle + listViewerStyle + ` +.r-interactive-output .r-list-viewer .arrow{display:inline-block;width:8px;height:8px;margin:4px} +.r-interactive-output .r-list-viewer .codicon-chevron-right{display:inline-block;width:6px;height:6px;border-top:1px solid currentColor;border-right:1px solid currentColor;transform:rotate(45deg);margin:4px} +.r-interactive-output .r-list-viewer details[open] > summary > .arrow{transform:rotate(135deg)} +.r-interactive-output .r-list-viewer .codicon-open-preview::before{content:'↗'} +.r-interactive-output .r-list-viewer .str{overflow-wrap:anywhere;min-width:0} +.r-interactive-output .r-list-viewer .label{min-width:64px} +`; element.append(style); const state: OutputState = { element, data: { ...data } }; outputs.set(item.id, state); const toolbar = document.createElement('div'); toolbar.className = 'r-interactive-toolbar'; @@ -104,6 +114,45 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out }, 'Text'); toolbar.prepend(open, toggle); showView(); state.dispose = () => { state.table?.dispose(); open.onclick = null; toggle.onclick = null; }; + } else if (data.kind === 'list') { + const tree = document.createElement('div'); tree.className = 'r-list-viewer'; tree.dataset.list = ''; + tree.style.cssText = 'display:flex;flex-direction:column;max-height:460px;overflow:hidden'; + const navigation = document.createElement('div'); navigation.className = 'navigation'; + const back = button('Back', 'previous', () => undefined); back.classList.add('back'); + const breadcrumbs = document.createElement('nav'); breadcrumbs.className = 'breadcrumbs'; + breadcrumbs.setAttribute('aria-label', 'Object path'); navigation.append(back, breadcrumbs); + const navigationStatus = document.createElement('div'); navigationStatus.className = 'navigation-status'; + navigationStatus.setAttribute('role', 'status'); + const list = document.createElement('div'); list.className = 'list'; + const reset = button('Reset list view', 'reset', () => undefined, 'Reset'); + reset.title = 'Return to the initial list, collapse expanded rows, and clear navigation history'; + tree.append(navigation, navigationStatus, list); element.append(tree); + const initial = data.navigation as ListViewNavigation ?? { title: 'List', path: [], breadcrumbs: [{ label: 'List', path: [] }] }; + state.list = createListViewer({ root: tree, list, back, reset, breadcrumbs, navigationStatus }, initial, message => { + const id = ++requestId; + const action = message.message === 'listview/page' ? 'listPage' : message.message === 'listview/navigate' ? 'listNavigate' : 'listItem'; + send(item, data, action, { path: message.path, index: message.index, start: message.start, requestId: id }); return id; + }, Array.isArray(data.children) ? data as unknown as ListViewPage : undefined, live); + const printed = document.createElement('pre'); printed.dataset.printed = ''; + printed.style.cssText = 'white-space:pre;overflow:auto;max-height:460px;font-family:var(--vscode-editor-font-family,monospace);font-size:var(--vscode-editor-font-size,12px)'; + printed.textContent = typeof data.printedText === 'string' ? data.printedText : ''; element.append(printed); + const open = button('Open list viewer', 'list', () => send(item, data, 'list', { path: state.list?.navigation().path }), 'List viewer'); + open.disabled = !live; + const hasText = typeof data.printedText === 'string'; + let text = hasText && (choice?.listView ?? data.listView) === 'text'; + const showView = (): void => { + tree.hidden = text; printed.hidden = !text; + toggle.replaceChildren(...Array.from(button('', text ? 'list' : 'text', () => undefined, text ? 'List' : 'Text').childNodes)); + toggle.title = text ? 'Show list preview' : 'Show R printed output'; toggle.setAttribute('aria-label', toggle.title); + toggle.disabled = !hasText; + if (!hasText) { toggle.title = data.printError ? `R printout unavailable: ${typeof data.printError === 'string' ? data.printError : JSON.stringify(data.printError)}` : 'Printed output unavailable'; } + }; + const toggle = button('Show R printed output', 'text', () => { + text = !text; remember(choiceKey, { listView: text ? 'text' : 'list' }); + send(item, data, 'listView', { mode: text ? 'text' : 'list' }); showView(); + }, 'Text'); + toolbar.append(open, toggle, reset); status.textContent = connectionHint(data); showView(); + state.dispose = () => { state.list?.dispose(); open.onclick = null; toggle.onclick = null; }; } else if (data.kind === 'plot') { const pages = (Array.isArray(data.pages) && data.pages.length ? data.pages : [data]) as Record[]; const selected = choice?.selectedPlot ?? data.selectedPlot; @@ -195,7 +244,7 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out toolbar.append(status); if (!toolbar.parentElement) { element.append(toolbar); } - if (data.kind !== 'table') { + if (!['table', 'list'].includes(String(data.kind))) { status.textContent = [status.textContent, connectionHint(data)].filter(Boolean).join(' · '); if (!connected && data.kind !== 'plot') { toolbar.querySelectorAll('button').forEach(control => { control.disabled = true; }); } } diff --git a/src/interactive/rendererToolbar.ts b/src/interactive/rendererToolbar.ts index f069fe42f..4ecf32d76 100644 --- a/src/interactive/rendererToolbar.ts +++ b/src/interactive/rendererToolbar.ts @@ -8,6 +8,7 @@ const paths = { reset: 'M3 6a5 5 0 1 1 0 5M3 2v4h4', filter: 'M2 3h12L9 8v5l-2 1V8Z', table: 'M2 2h12v12H2ZM2 6h12M6 2v12', + list: 'M2 3h1M6 3h8M2 8h1M6 8h8M2 13h1M6 13h8', text: 'M2 3h12M2 6h9M2 9h12M2 12h9', open: 'M9 2h5v5M14 2 7 9M6 3H2v11h11v-4', save: 'M8 2v8M4 6l4 4 4-4M2 10v4h12v-4', diff --git a/src/listViewer.ts b/src/listViewer.ts index a1aaa09e8..670966074 100644 --- a/src/listViewer.ts +++ b/src/listViewer.ts @@ -1,3 +1,5 @@ +/// + export interface ListViewNavigation { title: string; path: number[]; @@ -6,42 +8,57 @@ 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, reset, breadcrumbs, navigationStatus }: { + root: HTMLElement; list: HTMLElement; back: HTMLButtonElement; reset: 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 | undefined; + 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; + reset.disabled = !live; breadcrumbs.replaceChildren(); navigation.breadcrumbs.forEach((crumb, index) => { if (index) { @@ -55,34 +72,40 @@ 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, resetting = 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) { navigationStatus.textContent = response.error; } else if (response.navigation) { + if (resetting) { + pending.clear(); pages.clear(); history.length = 0; current = undefined; + } 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); } }); + reset.addEventListener('click', () => navigate('listview/navigate', { path: initial.path }, false, 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 +115,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 +157,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 +175,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 +190,130 @@ 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(); }, + }; +} + +/** Panel wrapper and its interaction tests. Keep panel focus/blur hooks here. */ +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'), reset: document.getElementById('reset'), + 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..434db9551 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,17 @@ 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} - + - -
+ +