From 597672b6aea59ced375517cf7c03a04b0943e7ce Mon Sep 17 00:00:00 2001 From: Jonas B <97200640+SmiteDeluxe@users.noreply.github.com> Date: Mon, 24 Jun 2024 14:32:10 +0200 Subject: [PATCH] feat: eda history basic setup (#1212) Closes #1211 ### Summary of Changes Fully basic history, with undo, redo and history display where user can skip to any other step. My Miro notes, not summarized for times sake: ![image](https://github.com/Safe-DS/DSL/assets/97200640/04b6b130-29bd-4794-a0c8-7ec9216e1ab8) --- packages/safe-ds-eda/src/App.svelte | 22 +- packages/safe-ds-eda/src/apis/extensionApi.ts | 60 ++ packages/safe-ds-eda/src/apis/historyApi.ts | 714 ++++++++++++++++-- .../safe-ds-eda/src/components/History.svelte | 88 +++ .../safe-ds-eda/src/components/Sidebar.svelte | 85 ++- .../src/components/TableView.svelte | 67 +- .../components/profiling/ProfilingInfo.svelte | 2 +- .../src/components/tabs/TabContent.svelte | 12 +- packages/safe-ds-eda/src/icons/History.svelte | 15 +- packages/safe-ds-eda/src/icons/Undo.svelte | 6 +- packages/safe-ds-eda/src/webviewState.ts | 78 +- packages/safe-ds-eda/types/messaging.ts | 56 +- packages/safe-ds-eda/types/state.ts | 21 +- .../src/extension/eda/apis/runnerApi.ts | 298 +++++++- .../src/extension/eda/edaPanel.ts | 85 +++ 15 files changed, 1435 insertions(+), 174 deletions(-) create mode 100644 packages/safe-ds-eda/src/components/History.svelte diff --git a/packages/safe-ds-eda/src/App.svelte b/packages/safe-ds-eda/src/App.svelte index e254a06d6..3f18b3204 100644 --- a/packages/safe-ds-eda/src/App.svelte +++ b/packages/safe-ds-eda/src/App.svelte @@ -2,7 +2,7 @@ import TableView from './components/TableView.svelte'; import Sidebar from './components/Sidebar.svelte'; import { throttle } from 'lodash'; - import { currentTabIndex, tabs } from './webviewState'; + import { currentTabIndex, tableKey, tabs } from './webviewState'; import TabContent from './components/tabs/TabContent.svelte'; let sidebarWidth = 307; // Initial width of the sidebar in pixels @@ -45,15 +45,19 @@
- + {#key $tableKey} + + {/key}
- {#if $tabs.length > 0} - {#each $tabs as tab, index} -
- -
- {/each} - {/if} + {#key $tableKey} + {#if $tabs.length > 0} + {#each $tabs as tab, index} +
+ +
+ {/each} + {/if} + {/key}
diff --git a/packages/safe-ds-eda/src/apis/extensionApi.ts b/packages/safe-ds-eda/src/apis/extensionApi.ts index fdb5a0b05..6fa3b238a 100644 --- a/packages/safe-ds-eda/src/apis/extensionApi.ts +++ b/packages/safe-ds-eda/src/apis/extensionApi.ts @@ -1,6 +1,8 @@ import { get } from 'svelte/store'; import type { HistoryEntry } from '../../types/state'; import { table } from '../webviewState'; +import type { ExecuteRunnerAllEntry } from '../../types/messaging'; +import { filterHistoryOnlyInternal } from './historyApi'; export const createInfoToast = function (message: string) { window.injVscode.postMessage({ command: 'setInfo', value: message }); @@ -21,6 +23,64 @@ const executeRunnerExcludingHiddenColumns = function ( }); }; +export const executeRunnerAll = function (entries: HistoryEntry[], jumpedToHistoryId: number) { + const currentEntries: HistoryEntry[] = []; + const finalEntries: ExecuteRunnerAllEntry[] = entries.map((entry) => { + currentEntries.push(entry); + if (entry.type === 'external-visualizing' && entry.columnNumber === 'none') { + // If the entry is a tab where you do not select columns => don't include hidden columns in visualization + // Hidden columns calculated by filtering the history for not overriden hide column calls up to this point + return { + type: 'excludingHiddenColumns', + entry, + hiddenColumns: filterHistoryOnlyInternal(currentEntries).reduce((acc, filteredEntry) => { + if (filteredEntry.action === 'hideColumn') { + acc.push(filteredEntry.columnName); + } + return acc; + }, []), + }; + } else { + return { type: 'default', entry }; + } + }); + window.injVscode.postMessage({ + command: 'executeRunnerAll', + value: { entries: finalEntries, jumpedToHistoryId }, + }); +}; + +export const executeRunnerAllFuture = function ( + futureEntries: HistoryEntry[], + pastEntries: HistoryEntry[], + jumpedToHistoryId: number, +) { + const currentEntries: HistoryEntry[] = pastEntries; + const finalFutureEntries: ExecuteRunnerAllEntry[] = futureEntries.map((entry) => { + currentEntries.push(entry); + if (entry.type === 'external-visualizing' && entry.columnNumber === 'none') { + // If the entry is a tab where you do not select columns => don't include hidden columns in visualization + // Hidden columns calculated by filtering the history for not overriden hide column calls up to this point + return { + type: 'excludingHiddenColumns', + entry, + hiddenColumns: filterHistoryOnlyInternal(currentEntries).reduce((acc, filteredEntry) => { + if (filteredEntry.action === 'hideColumn') { + acc.push(filteredEntry.columnName); + } + return acc; + }, []), + }; + } else { + return { type: 'default', entry }; + } + }); + window.injVscode.postMessage({ + command: 'executeRunnerAllFuture', + value: { futureEntries: finalFutureEntries, pastEntries, jumpedToHistoryId }, + }); +}; + const executeRunnerDefault = function (pastEntries: HistoryEntry[], newEntry: HistoryEntry) { window.injVscode.postMessage({ command: 'executeRunner', diff --git a/packages/safe-ds-eda/src/apis/historyApi.ts b/packages/safe-ds-eda/src/apis/historyApi.ts index 2b659d083..17364aff9 100644 --- a/packages/safe-ds-eda/src/apis/historyApi.ts +++ b/packages/safe-ds-eda/src/apis/historyApi.ts @@ -1,9 +1,11 @@ -import { get } from 'svelte/store'; +/* eslint-disable no-console */ +import { get, writable } from 'svelte/store'; import type { FromExtensionMessage, RunnerExecutionResultMessage } from '../../types/messaging'; import type { CategoricalFilter, EmptyTab, ExternalHistoryEntry, + FullInternalHistoryEntry, HistoryEntry, InteralEmptyTabHistoryEntry, InternalHistoryEntry, @@ -12,13 +14,28 @@ import type { Tab, TabHistoryEntry, } from '../../types/state'; -import { cancelTabIdsWaiting, tabs, history, currentTabIndex, table, tableLoading } from '../webviewState'; -import { executeRunner } from './extensionApi'; +import { + cancelTabIdsWaiting, + tabs, + history, + currentTabIndex, + table, + tableLoading, + savedColumnWidths, + restoreTableInitialState, + rerender, +} from '../webviewState'; +import { executeRunner, executeRunnerAll, executeRunnerAllFuture } from './extensionApi'; // Wait for results to return from the server const asyncQueue: (ExternalHistoryEntry & { id: number })[] = []; let messagesWaitingForTurn: RunnerExecutionResultMessage[] = []; let entryIdCounter = 0; +export let currentHistoryIndex = writable(-1); // -1 = last entry, 0 = first entry +let relevantJumpedToHistoryId: number | undefined; +export let undoEntry = writable(undefined); +export let redoEntry = writable(undefined); +let jumpToTabOnHistoryChange: Tab | undefined | null = null; // If set to not null and a multipleRunnerExecutionResult message comes in, the last focused tab will be updated if it is still there export const getAndIncrementEntryId = function (): number { return entryIdCounter++; @@ -28,10 +45,12 @@ const generateOverrideId = function (entry: ExternalHistoryEntry | InternalHisto switch (entry.action) { case 'hideColumn': case 'showColumn': + return entry.columnName + '.visibility'; case 'resizeColumn': - case 'reorderColumns': case 'highlightColumn': return entry.columnName + '.' + entry.action; + case 'reorderColumns': + return 'reorderColumns'; // As reorder action contains all columns order case 'sortByColumn': return entry.action; // Thus enforcing override sort case 'voidSortByColumn': @@ -46,7 +65,7 @@ const generateOverrideId = function (entry: ExternalHistoryEntry | InternalHisto case 'heatmap': case 'emptyTab': const tabId = entry.newTabId ?? entry.existingTabId; - return entry.type + '.' + tabId; + return 'visualizing.' + tabId; default: throw new Error('Unknown action type to generateOverrideId'); } @@ -68,7 +87,7 @@ window.addEventListener('message', (event) => { return; } - deployResult(message, asyncQueue[0]); + deployResult(message.value, asyncQueue[0]); asyncQueue.shift(); if (asyncQueue.length === 0) { @@ -78,38 +97,144 @@ window.addEventListener('message', (event) => { evaluateMessagesWaitingForTurn(); } else if (message.command === 'cancelRunnerExecution') { cancelExecuteExternalHistoryEntry(message.value); + } else if (message.command === 'multipleRunnerExecutionResult') { + if (message.value.results.length === 0) return; + if (relevantJumpedToHistoryId === message.value.jumpedToHistoryId) { + // Only deploy if the last message is the one that was jumped to + + const results = message.value.results; + const currentHistory = get(history); + const historyMap = new Map(currentHistory.map((entry) => [entry.id, entry])); + + if (message.value.type === 'past') { + restoreTableInitialState(); + } else { + rerender(); + } + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const entry = historyMap.get(result.historyId); + if (!entry) throw new Error('Entry not found for result'); + if (entry.type === 'internal') throw new Error('Internal entry found for external result'); + deployResult(result, entry, false); + } + + let relevantJumpedToIndex = -1; + let relevantJumpedToEntry: HistoryEntry | undefined; + for (let i = 0; i < currentHistory.length; i++) { + if (currentHistory[i].id === relevantJumpedToHistoryId) { + relevantJumpedToIndex = i; + relevantJumpedToEntry = currentHistory[i]; + break; + } + } + + // Redo all internal history things considering overrideIds + redoInternalHistory(currentHistory.slice(0, relevantJumpedToIndex + 1)); + + // Restore tab order for still existing tabs + tabs.update((state) => { + const newTabs = relevantJumpedToEntry!.tabOrder.map((tabOrderId) => { + const inState = state.find((t) => t.id === tabOrderId); + if (!inState) { + throw new Error('Tab from tab order not found in state'); + } + return inState; + }); + + return newTabs; + }); + + // Set currentTabIndex + let overridejumpToTabOnHistoryChange = false; + if (jumpToTabOnHistoryChange !== null) { + if (jumpToTabOnHistoryChange === undefined) { + currentTabIndex.set(undefined); + } else { + const indexOfjumpToTabOnHistoryChange = get(tabs).findIndex( + (t) => t.id === jumpToTabOnHistoryChange!.id, + ); + if (indexOfjumpToTabOnHistoryChange !== -1) { + currentTabIndex.set(indexOfjumpToTabOnHistoryChange); + } else { + overridejumpToTabOnHistoryChange = true; + // eslint-disable-next-line no-console + console.error('Last focused tab not found in tabs'); + } + } + } + if (jumpToTabOnHistoryChange === null || overridejumpToTabOnHistoryChange) { + if (relevantJumpedToEntry!.type === 'internal') { + if (relevantJumpedToEntry!.action === 'emptyTab') { + currentTabIndex.set(get(tabs).findIndex((t) => t.id === relevantJumpedToEntry!.newTabId)); + } else { + currentTabIndex.set(undefined); + } + } else if (relevantJumpedToEntry!.type === 'external-visualizing') { + currentTabIndex.set( + get(tabs).findIndex( + (t) => t.id === (relevantJumpedToEntry!.existingTabId ?? relevantJumpedToEntry!.newTabId), + ), + ); + } else { + currentTabIndex.set(undefined); + } + } + jumpToTabOnHistoryChange = null; + relevantJumpedToHistoryId = undefined; + tableLoading.set(false); + } } }); -export const addInternalToHistory = function (entry: InternalHistoryEntry): void { +const overrideUndoneEntries = function (): void { + if (get(currentHistoryIndex) <= get(history).length - 1) { + // Remove all entries after currentHistoryIndex + history.update((state) => state.slice(0, get(currentHistoryIndex) + 1)); + } +}; + +export const addInternalToHistory = function (entry: Exclude): void { + overrideUndoneEntries(); history.update((state) => { const entryWithId: HistoryEntry = { ...entry, id: getAndIncrementEntryId(), overrideId: generateOverrideId(entry), + tabOrder: generateTabOrder(), // Based on that entry cannot be a new tab }; const newHistory = [...state, entryWithId]; + currentHistoryIndex.set(newHistory.length - 1); return newHistory; }); - - updateTabOutdated(entry); }; export const executeExternalHistoryEntry = function (entry: ExternalHistoryEntry): void { // Set table to loading if loading takes longer than 500ms - setTimeout(() => { - if (asyncQueue.length > 0) { - tableLoading.set(true); - } - }, 500); + if (entry.type === 'external-manipulating') + setTimeout(() => { + if (asyncQueue.length > 0) { + tableLoading.set(true); + } + }, 500); + const tabOrder = generateTabOrder(); + if (entry.type === 'external-visualizing' && entry.newTabId) { + tabOrder.push(entry.newTabId); + } + + overrideUndoneEntries(); history.update((state) => { const entryWithId: HistoryEntry = { ...entry, id: getAndIncrementEntryId(), overrideId: generateOverrideId(entry), + loading: true, + tabOrder, }; const newHistory = [...state, entryWithId]; + currentHistoryIndex.set(newHistory.length - 1); asyncQueue.push(entryWithId); executeRunner(state, entryWithId); @@ -134,13 +259,16 @@ export const addAndDeployTabHistoryEntry = function (entry: TabHistoryEntry & { return; } - history.update((state) => { - return [...state, { ...entry, overrideId: generateOverrideId(entry) }]; - }); + overrideUndoneEntries(); tabs.update((state) => { const newTabs = (state ?? []).concat(tab); return newTabs; }); + const tabOrder = generateTabOrder(); + history.update((state) => { + currentHistoryIndex.set(state.length); + return [...state, { ...entry, overrideId: generateOverrideId(entry), tabOrder }]; + }); currentTabIndex.set(get(tabs).indexOf(tab)); }; @@ -149,7 +277,7 @@ export const addEmptyTabHistoryEntry = function (): void { const entry: InteralEmptyTabHistoryEntry & { id: number } = { action: 'emptyTab', type: 'internal', - alias: 'New empty tab', + alias: 'New tab', id: getAndIncrementEntryId(), newTabId: tabId, }; @@ -159,17 +287,20 @@ export const addEmptyTabHistoryEntry = function (): void { isInGeneration: true, }; - history.update((state) => { - return [...state, { ...entry, overrideId: generateOverrideId(entry) }]; - }); + overrideUndoneEntries(); tabs.update((state) => { const newTabs = (state ?? []).concat(tab); return newTabs; }); + const tabOrder = generateTabOrder(); + history.update((state) => { + currentHistoryIndex.set(state.length); + return [...state, { ...entry, overrideId: generateOverrideId(entry), tabOrder }]; + }); currentTabIndex.set(get(tabs).indexOf(tab)); }; -export const cancelExecuteExternalHistoryEntry = function (entry: HistoryEntry): void { +export const cancelExecuteExternalHistoryEntry = function (entry: HistoryEntry, removeEntry = true): void { const index = asyncQueue.findIndex((queueEntry) => queueEntry.id === entry.id); if (index !== -1) { asyncQueue.splice(index, 1); @@ -183,6 +314,25 @@ export const cancelExecuteExternalHistoryEntry = function (entry: HistoryEntry): } } + if (entry.loading && !removeEntry) { + history.update((state) => { + return state.map((e) => { + if (e.id === entry.id) { + return { + ...e, + loading: false, + }; + } else { + return e; + } + }); + }); + } + + if (removeEntry) { + history.update((state) => state.filter((e) => e.id !== entry.id)); + } + if (asyncQueue.length === 0) { tableLoading.set(false); } @@ -208,6 +358,233 @@ export const setTabAsGenerating = function (tab: RealTab): void { }); }; +//#region History navigation +//#region Redo +export const redoHistoryEntries = function (upToHistoryId: number): void { + jumpToTabOnHistoryChange = null; // In redo we always want to jump to the tab of the last relevant entry + + const currentHistory = get(history); + const lastRelevantEntry = currentHistory.find((entry) => entry.id === upToHistoryId); + if (!lastRelevantEntry) throw new Error('Entry not found in history'); + const lastRelevantEntryIndex = currentHistory.indexOf(lastRelevantEntry); + if (lastRelevantEntryIndex <= get(currentHistoryIndex)) return; // Do not redo if the entry is the or before the current index + + const previousIndex = get(currentHistoryIndex); + currentHistoryIndex.set(lastRelevantEntryIndex); + + // If the entries since the previous index are only internal, we can just redo them + if ( + currentHistory.slice(previousIndex + 1, lastRelevantEntryIndex + 1).every((entry) => entry.type === 'internal') + ) { + redoInternalHistory(currentHistory.slice(previousIndex + 1, lastRelevantEntryIndex + 1)); + + if (lastRelevantEntry.action === 'emptyTab') { + currentTabIndex.set(get(tabs).findIndex((t) => t.id === lastRelevantEntry.newTabId)); + } else { + currentTabIndex.set(undefined); + } + + return; + } + + relevantJumpedToHistoryId = upToHistoryId; + // Set table to loading if loading takes longer than 500ms + setTimeout(() => { + if (relevantJumpedToHistoryId) { + tableLoading.set(true); // Warning: does not check if there are any actual manipulating entries, but this is only loading anyway + } + }, 500); + + // Set entry at lastRelevantEntryIndex to loading and all others to not loading + history.update((state) => { + const newHistory = state.map((entry, index) => { + if (index === lastRelevantEntryIndex) { + return { + ...entry, + loading: true, + }; + } else { + return { + ...entry, + loading: false, + }; + } + }); + + return newHistory; + }); + + executeRunnerAllFuture( + currentHistory.slice(previousIndex + 1, lastRelevantEntryIndex + 1), + currentHistory.slice(0, previousIndex + 1), + upToHistoryId, + ); +}; + +export const redoLastHistoryEntry = function (): void { + const currentHistoryIndexValue = get(currentHistoryIndex); + const currentHistory = get(history); + if (currentHistoryIndexValue + 1 === currentHistory.length) { + // Already at last entry + return; + } + + const nextEntry = currentHistory[currentHistoryIndexValue + 1]; + + redoHistoryEntries(nextEntry.id); +}; + +export const getRedoEntry = function (): HistoryEntry | undefined { + const currentHistoryIndexValue = get(currentHistoryIndex); + const currentHistory = get(history); + if (currentHistoryIndexValue + 1 === currentHistory.length) { + return undefined; + } + + return currentHistory[currentHistoryIndexValue + 1]; +}; +//#endregion +//#region Undo +export const undoHistoryEntries = function (upToHistoryId: number): void { + const currentHistory = get(history); + const lastRelevantEntry = currentHistory.find((entry) => entry.id === upToHistoryId); + if (!lastRelevantEntry) throw new Error('Entry not found in history'); + const lastRelevantEntryIndex = currentHistory.indexOf(lastRelevantEntry); + if (lastRelevantEntryIndex >= get(currentHistoryIndex)) return; // Do not undo if the entry is the or after the current index + + currentHistoryIndex.set(lastRelevantEntryIndex); + + // Try cancelling any asyncQueue entries that are not yet executed and after the last relevant entry + for (let i = currentHistory.length - 1; i > lastRelevantEntryIndex; i--) { + const entry = currentHistory[i]; + if (entry.type === 'internal') { + continue; + } + if (entry.loading) { + try { + cancelExecuteExternalHistoryEntry(entry, false); + } catch (error) { + history.update((state) => { + return state.map((e) => { + if (e.id === entry.id) { + return { + ...e, + loading: false, + }; + } else { + return e; + } + }); + }); + // eslint-disable-next-line no-console + console.error('Could not cancel entry', error); + } + } + } + + // If the entries until relevant entry are only internal, we can just redo them + if (currentHistory.slice(0, lastRelevantEntryIndex + 1).every((entry) => entry.type === 'internal')) { + restoreTableInitialState(); + redoInternalHistory(currentHistory.slice(0, lastRelevantEntryIndex + 1)); + + if (jumpToTabOnHistoryChange === null) { + if (lastRelevantEntry.action === 'emptyTab') { + currentTabIndex.set(get(tabs).findIndex((t) => t.id === lastRelevantEntry.newTabId)); + } else { + currentTabIndex.set(undefined); + } + } + + jumpToTabOnHistoryChange = null; + return; + } + + relevantJumpedToHistoryId = upToHistoryId; + // Set table to loading if loading takes longer than 500ms + setTimeout(() => { + if (relevantJumpedToHistoryId) { + tableLoading.set(true); // Warning: does not check if there are any actual manipulating entries, but this is only loading anyway + } + }, 500); + + // Set entry at lastRelevantEntryIndex to loading + history.update((state) => { + const newHistory = state.map((entry, index) => { + if (index === lastRelevantEntryIndex) { + return { + ...entry, + loading: true, + }; + } else { + return entry; + } + }); + + return newHistory; + }); + + executeRunnerAll(currentHistory.slice(0, lastRelevantEntryIndex + 1), upToHistoryId); +}; + +export const undoLastHistoryEntry = function (): void { + const currentHistoryIndexValue = get(currentHistoryIndex); + const currentHistory = get(history); + if (currentHistoryIndexValue + 1 === 0) { + return; + } + if (currentHistoryIndexValue + 1 === 1) { + relevantJumpedToHistoryId = undefined; + restoreTableInitialState(); + currentHistoryIndex.set(-1); + currentTabIndex.set(undefined); + return; + } + + const beforeLastEntry = currentHistory[currentHistoryIndexValue - 1]; + const lastEntry = currentHistory[currentHistoryIndexValue]; + const currentTabIndexValue = get(currentTabIndex); + const currentTab = currentTabIndexValue !== undefined ? get(tabs)[currentTabIndexValue] : undefined; + + // Do not change forcus if the undone action is in table or in a tab that still exists after undo + if (lastEntry.type === 'external-manipulating') { + jumpToTabOnHistoryChange = undefined; + } else if (lastEntry.type === 'external-visualizing') { + const tabId = lastEntry.existingTabId; + if (tabId !== undefined) { + jumpToTabOnHistoryChange = get(tabs).find((t) => t.id === tabId) ?? null; + } else { + if (currentTab?.id === lastEntry.newTabId) { + jumpToTabOnHistoryChange = null; + } else { + jumpToTabOnHistoryChange = currentTab; + } // If not existingTabId, it is a new tab and the current tab can only stay in focus if it is not that tab + } + } else { + if (lastEntry.action === 'emptyTab') { + if (currentTab?.id === lastEntry.newTabId) { + jumpToTabOnHistoryChange = null; + } else { + jumpToTabOnHistoryChange = currentTab; + } // Current tab can only stay in focus if it is not that new tab + } else { + jumpToTabOnHistoryChange = undefined; + } + } + undoHistoryEntries(beforeLastEntry.id); +}; + +export const getUndoEntry = function (): HistoryEntry | undefined { + const currentHistoryIndexValue = get(currentHistoryIndex); + const currentHistory = get(history); + if (currentHistoryIndexValue + 1 === 0) { + return undefined; + } + + return currentHistory[currentHistoryIndexValue]; +}; +//#endregion +//#endregion + export const unsetTabAsGenerating = function (tab: RealTab): void { tabs.update((state) => { const newTabs = state.map((t) => { @@ -228,8 +605,11 @@ export const unsetTabAsGenerating = function (tab: RealTab): void { }); }; -const deployResult = function (result: RunnerExecutionResultMessage, historyEntry: ExternalHistoryEntry) { - const resultContent = result.value; +const deployResult = function ( + resultContent: RunnerExecutionResultMessage['value'], + historyEntry: ExternalHistoryEntry & { id: number }, + updateFocusedTab = true, +) { if (resultContent.type === 'tab') { if (historyEntry.type !== 'external-visualizing') throw new Error('Deploying tab from non-visualizing entry'); if (historyEntry.existingTabId) { @@ -245,47 +625,81 @@ const deployResult = function (result: RunnerExecutionResultMessage, historyEntr } }), ); - currentTabIndex.set(tabIndex); - return; + if (updateFocusedTab) currentTabIndex.set(tabIndex); + } else { + // This will happen if called from executeRunnerAll as we reset tabs before, they are re-sorted later so all good + const tab = resultContent.content; + tab.id = historyEntry.existingTabId; + tabs.update((state) => state.concat(tab)); + if (updateFocusedTab) currentTabIndex.set(get(tabs).indexOf(tab)); } } else { const tab = resultContent.content; tab.id = historyEntry.newTabId!; // Must exist if not existingTabId, not sure why ts does not pick up on it itself here tabs.update((state) => state.concat(tab)); - currentTabIndex.set(get(tabs).indexOf(tab)); + if (updateFocusedTab) currentTabIndex.set(get(tabs).indexOf(tab)); } } else if (resultContent.type === 'table') { table.update((state) => { - for (const column of resultContent.content.columns) { - const existingColumn = state?.columns.find((c) => c.name === column.name); - if (!existingColumn) throw new Error('New Column not found in current table!'); - - column.profiling = existingColumn.profiling; // Preserve profiling, after this if it was a type that invalidated profiling, it will be invalidated - column.hidden = existingColumn.hidden; - column.highlighted = existingColumn.highlighted; - if (historyEntry.action === 'sortByColumn' && column.name === historyEntry.columnName) { - column.appliedSort = historyEntry.sort; // Set sorted column to sorted if it was a sort action, otherwise if also not a void sort preserve + if (!state) { + throw new Error('State is not defined!'); + } + + const updatedColumns = state.columns.map((existingColumn) => { + const newColumn = resultContent.content.columns.find((c) => c.name === existingColumn.name); + if (!newColumn) throw new Error(`Column ${existingColumn.name} not found in new content!`); + + // Update properties from the new column + newColumn.profiling = existingColumn.profiling; + newColumn.hidden = existingColumn.hidden; + newColumn.highlighted = existingColumn.highlighted; + + if (historyEntry.action === 'sortByColumn' && newColumn.name === historyEntry.columnName) { + newColumn.appliedSort = historyEntry.sort; } else if (historyEntry.action !== 'sortByColumn' && historyEntry.action !== 'voidSortByColumn') { - column.appliedSort = existingColumn.appliedSort; + newColumn.appliedSort = existingColumn.appliedSort; } - if (historyEntry.action === 'filterColumn' && column.name === historyEntry.columnName) { + + if (historyEntry.action === 'filterColumn' && newColumn.name === historyEntry.columnName) { if (existingColumn.type === 'numerical') { - column.appliedFilters = existingColumn.appliedFilters.concat([ + newColumn.appliedFilters = existingColumn.appliedFilters.concat([ historyEntry.filter as NumericalFilter, - ]); // Set filtered column to filtered if it was a filter action, otherwise preserve + ]); } else if (existingColumn.type === 'categorical') { - column.appliedFilters = existingColumn.appliedFilters.concat([ + newColumn.appliedFilters = existingColumn.appliedFilters.concat([ historyEntry.filter as CategoricalFilter, - ]); // Set filtered column to filtered if it was a filter action, otherwise preserve + ]); } } else if (historyEntry.action !== 'filterColumn') { - column.appliedFilters = existingColumn.appliedFilters; + newColumn.appliedFilters = existingColumn.appliedFilters; } - } - return resultContent.content; + + return newColumn; + }); + + return { + ...state, + columns: updatedColumns, + }; }); - updateTabOutdated(historyEntry); + if (updateFocusedTab) currentTabIndex.set(undefined); + } + + // Set loading to false + if (historyEntry.loading) { + history.update((state) => { + return state.map((entry) => { + if (entry.id === historyEntry.id) { + return { + ...entry, + loading: false, + }; + } else { + return entry; + } + }); + }); } }; @@ -297,7 +711,7 @@ const evaluateMessagesWaitingForTurn = function () { if (asyncQueue[0].id === entry.value.historyId) { // eslint-disable-next-line no-console console.log(`Deploying message from waiting queue: ${entry}`); - deployResult(entry, asyncQueue[0]); + deployResult(entry.value, asyncQueue[0]); asyncQueue.shift(); firstItemQueueChanged = true; } else if (asyncQueue.findIndex((queueEntry) => queueEntry.id === entry.value.historyId) !== -1) { @@ -309,26 +723,196 @@ const evaluateMessagesWaitingForTurn = function () { if (firstItemQueueChanged) evaluateMessagesWaitingForTurn(); // Only if first element was deployed we have to scan again, as this is only deployment condition }; -const updateTabOutdated = function (entry: ExternalHistoryEntry | InternalHistoryEntry): void { - if (entry.action === 'hideColumn' || entry.action === 'showColumn') { - tabs.update((state) => { - const newTabs = state.map((t) => { - if ( - t.type !== 'empty' && - t.columnNumber === 'none' && - get(table)?.columns.find((c) => c.name === entry.columnName)?.type === 'numerical' - ) { - // UPDATE the if in case there are none column tabs that do not depend on numerical columns +const updateTabOutdated = function (): void { + const currentHistory = get(history).slice(0, get(currentHistoryIndex) + 1); + const currentTable = get(table); + if (!currentTable) return; + + const relevantToggleColumnEntries = currentHistory + .filter( + (e) => + (e.action === 'hideColumn' || e.action === 'showColumn') && + currentTable.columns.find((c) => c.name === e.columnName)?.type === 'numerical', + ) + .map((e) => { + return { entry: e, index: currentHistory.indexOf(e) }; + }); + + tabs.update((state) => { + const newTabs = state.map((t) => { + let outdated = false; + + if (t.type !== 'empty' && t.columnNumber === 'none') { + for (const entry of relevantToggleColumnEntries) { + // Find out if one of the outdating entries was after the last time the tab was updated + let lastTabUpdateIndex = -1; + for (let i = currentHistory.length - 1; i >= 0; i--) { + const currentEntry = currentHistory[i]; + if ( + currentEntry.type === 'external-visualizing' && + (currentEntry.existingTabId ?? currentEntry.newTabId) === t.id + ) { + lastTabUpdateIndex = i; + break; + } + } + if (lastTabUpdateIndex === -1) { + throw new Error('Tab not found in history'); + } + if (entry.index > lastTabUpdateIndex) { + // UPDATE the if in case there are none column tabs that do not depend on numerical columns + outdated = true; + } + } + return { + ...t, + outdated, + }; + } else { + return t; + } + }); + + return newTabs; + }); +}; + +export const filterHistoryOnlyInternal = function (entries: HistoryEntry[]): FullInternalHistoryEntry[] { + // Keep only the last occurrence of each unique overrideId + const lastOccurrenceMap = new Map(); + const filteredEntries: HistoryEntry[] = []; + + // Traverse from end to start to record the last occurrence of each unique overrideId + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]!; + const overrideId = entry.overrideId; + + if (!lastOccurrenceMap.has(overrideId)) { + lastOccurrenceMap.set(overrideId, i); + } + } + + // Traverse from start to end to build the final result with only the last occurrences + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const overrideId = entry.overrideId; + + if (lastOccurrenceMap.get(overrideId) === i) { + filteredEntries.push(entry); + } + } + + return filteredEntries.filter((entry) => entry.type === 'internal') as FullInternalHistoryEntry[]; +}; + +const redoInternalHistory = function (historyEntries: HistoryEntry[]): void { + const entries = filterHistoryOnlyInternal(historyEntries); + + for (const entry of entries) { + switch (entry.action) { + case 'hideColumn': + case 'showColumn': + table.update((state) => { + const newColumns = state!.columns.map((column) => { + if (column.name === entry.columnName) { + return { + ...column, + hidden: entry.action === 'hideColumn', + }; + } else { + return column; + } + }); + return { - ...t, - outdated: true, + ...state!, + columns: newColumns, }; - } else { - return t; - } - }); + }); + break; + case 'resizeColumn': + savedColumnWidths.update((state) => { + const newWidths = new Map(state); + newWidths.set(entry.columnName, entry.value); + return newWidths; + }); + break; + case 'reorderColumns': + table.update((state) => { + // Create a map to quickly find columns by their name + const columnMap = new Map(state!.columns.map((column) => [column.name, column])); + + const newColumns = entry.columnOrder.map((name) => columnMap.get(name)!); - return newTabs; - }); + return { + ...state!, + columns: newColumns, + }; + }); + break; + case 'highlightColumn': + throw new Error('Highlighting not implemented'); + case 'emptyTab': + const tab: EmptyTab = { + type: 'empty', + id: entry.newTabId, + isInGeneration: true, + }; + tabs.update((state) => { + const newTabs = (state ?? []).concat(tab); + return newTabs; + }); + break; + } + + if (entry.loading) { + history.update((state) => { + return state.map((e) => { + if (e.id === entry.id) { + return { + ...e, + loading: false, + }; + } else { + return e; + } + }); + }); + } } }; + +const generateTabOrder = function (): string[] { + const tabOrder = get(tabs).map((tab) => tab.id); + return tabOrder; +}; + +let stillWaitingToExecute = false; // Only one table update can be processed at a time +table.subscribe(async (_value) => { + if (stillWaitingToExecute) return; + stillWaitingToExecute = true; + // Things can only get outdated when table updates, but have to wait for all history entries to be final, can still be canceled or not even entered yet + await new Promise((resolve) => { + setTimeout(() => { + const checkConditions = () => { + if (relevantJumpedToHistoryId === undefined && asyncQueue.length === 0) { + stillWaitingToExecute = false; + resolve(); + } else { + setTimeout(checkConditions, 100); + } + }; + checkConditions(); + }, 150); // Initial wait of 150ms, either waiting for external, thus checkConditions, or for internal thus initial wait to wait for history entry to be there + }); + updateTabOutdated(); +}); + +history.subscribe((_value) => { + undoEntry.set(getUndoEntry()); +}); + +currentHistoryIndex.subscribe((_value) => { + undoEntry.set(getUndoEntry()); + redoEntry.set(getRedoEntry()); +}); diff --git a/packages/safe-ds-eda/src/components/History.svelte b/packages/safe-ds-eda/src/components/History.svelte new file mode 100644 index 000000000..ebf8b0e2d --- /dev/null +++ b/packages/safe-ds-eda/src/components/History.svelte @@ -0,0 +1,88 @@ + + +
+ {#if $history.length === 0} + No history + {/if} + {#each $history as historyItem, index} + + $currentHistoryIndex > index + ? undoHistoryEntries(historyItem.id) + : $currentHistoryIndex < index + ? redoHistoryEntries(historyItem.id) + : null} + > + {index + 1}. {historyItem.alias} + {#if historyItem.loading} + + {/if} + + {/each} +
+ + diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index 5a7edbec1..2d173480a 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -7,9 +7,13 @@ import SidebarTab from './tabs/SidebarTab.svelte'; import NewTabButton from './NewTabButton.svelte'; import ColumnCounts from './ColumnCounts.svelte'; + import History from './History.svelte'; + import { redoEntry, redoLastHistoryEntry, undoEntry, undoLastHistoryEntry } from '../apis/historyApi'; export let width: number; + let historyFocused = false; + const changeTab = function (index?: number) { if (!$preventClicks) { currentTabIndex.update((_cs) => index); @@ -28,34 +32,63 @@ {#if width > 50}
- {#if width > 200}History{/if} (historyFocused = !historyFocused)} + >{#if width > 200}History{/if} - {#if width > 200}Undo{/if} undoLastHistoryEntry()} + title={$undoEntry?.alias ?? ''} + >{#if width > 200}Undo{/if} - {#if width > 200}Redo{/if} redoLastHistoryEntry()} + title={$redoEntry?.alias ?? ''} + >{#if width > 200}Redo{/if}
{/if} -
- {#if width > 50} - - {#if $tabs} - {#each $tabs as tab, index} - - {/each} + {#if !historyFocused} +
+ {#if width > 50} + + {#if $tabs} + {#each $tabs as tab, index} + + {/each} + {/if} {/if} +
+ {#if width > 50} +
+ +
{/if} -
- {#if width > 50} -
- + {:else if width > 150} +
+
{/if} {#if width > 109} @@ -124,6 +157,11 @@ cursor: pointer; } + .historyFocused { + font-weight: bold; + font-size: 1.13rem; + } + .titleBar { display: flex; flex-direction: row; @@ -226,6 +264,11 @@ margin-bottom: 100px; } + .inactive { + color: var(--dark-color); + cursor: not-allowed; + } + @media (max-width: 300px) { .historyBar .icon { display: none; diff --git a/packages/safe-ds-eda/src/components/TableView.svelte b/packages/safe-ds-eda/src/components/TableView.svelte index ac217c889..17fe61477 100644 --- a/packages/safe-ds-eda/src/components/TableView.svelte +++ b/packages/safe-ds-eda/src/components/TableView.svelte @@ -1,7 +1,7 @@ + + + + + /> + diff --git a/packages/safe-ds-eda/src/icons/Undo.svelte b/packages/safe-ds-eda/src/icons/Undo.svelte index f0f87726a..d9f3d6112 100644 --- a/packages/safe-ds-eda/src/icons/Undo.svelte +++ b/packages/safe-ds-eda/src/icons/Undo.svelte @@ -1,6 +1,10 @@ + + diff --git a/packages/safe-ds-eda/src/webviewState.ts b/packages/safe-ds-eda/src/webviewState.ts index d012679c8..0bd1cf0c8 100644 --- a/packages/safe-ds-eda/src/webviewState.ts +++ b/packages/safe-ds-eda/src/webviewState.ts @@ -1,5 +1,5 @@ import type { FromExtensionMessage } from '../types/messaging'; -import type { HistoryEntry, Tab, Table } from '../types/state'; +import type { HistoryEntry, Profiling, Tab, Table } from '../types/state'; import { get, writable } from 'svelte/store'; const tabs = writable([]); @@ -10,10 +10,18 @@ const preventClicks = writable(false); const cancelTabIdsWaiting = writable([]); +let initialTable: Table | undefined; +const tableKey = writable(0); // If changed will remount the table +let initialProfiling: { columnName: string; profiling: Profiling }[] = []; +const tabKey = writable(0); // If changed will remount the tabs + +const showProfiling = writable(false); + // Define the stores, current state to default in case the extension never calls setWebviewState( Shouldn't happen) const table = writable(); const history = writable([]); +const savedColumnWidths = writable(new Map()); const tableLoading = writable(false); @@ -25,31 +33,65 @@ window.addEventListener('message', (event) => { case 'setInitialTable': if (!get(table)) { table.set(message.value); + initialTable = message.value; } else { throw new Error('setInitialTable called more than once'); } break; case 'setProfiling': if (get(table)) { - table.update((currentTable) => { - return { - ...currentTable!, - columns: currentTable!.columns.map((column) => { - const profiling = message.value.find((p) => p.columnName === column.name); - if (profiling) { - return { - ...column, - profiling: profiling.profiling, - }; - } else { - return column; - } - }), - }; - }); + initialProfiling = message.value; + setProfiling(message.value); } break; } }); -export { history, tabs, table, currentTabIndex, preventClicks, cancelTabIdsWaiting, tableLoading }; +const setProfiling = (profiling: { columnName: string; profiling: Profiling }[]) => { + table.update((currentTable) => { + return { + ...currentTable!, + columns: currentTable!.columns.map((column) => { + const newProfiling = profiling.find((p) => p.columnName === column.name); + if (newProfiling) { + return { + ...column, + profiling: newProfiling.profiling, + }; + } else { + return column; + } + }), + }; + }); +}; + +const restoreTableInitialState = () => { + table.set(initialTable); + tabs.set([]); + setProfiling(initialProfiling); + cancelTabIdsWaiting.set([]); + savedColumnWidths.set(new Map()); + rerender(); +}; + +const rerender = () => { + tableKey.update((key) => key + 1); + tabKey.update((key) => key + 1); +}; + +export { + history, + tabs, + table, + currentTabIndex, + preventClicks, + cancelTabIdsWaiting, + tableLoading, + savedColumnWidths, + restoreTableInitialState, + rerender, + tableKey, + tabKey, + showProfiling, +}; diff --git a/packages/safe-ds-eda/types/messaging.ts b/packages/safe-ds-eda/types/messaging.ts index 346320ba4..889be1611 100644 --- a/packages/safe-ds-eda/types/messaging.ts +++ b/packages/safe-ds-eda/types/messaging.ts @@ -1,7 +1,14 @@ import * as defaultTypes from './state.js'; // To extension -type ToExtensionCommand = 'setCurrentGlobalState' | 'resetGlobalState' | 'setInfo' | 'setError' | 'executeRunner'; +type ToExtensionCommand = + | 'setCurrentGlobalState' + | 'resetGlobalState' + | 'setInfo' + | 'setError' + | 'executeRunner' + | 'executeRunnerAll' + | 'executeRunnerAllFuture'; interface ToExtensionCommandMessage { command: ToExtensionCommand; @@ -27,7 +34,7 @@ interface ToExtensionExecuteRunnerMessage extends ToExtensionCommandMessage { }; } -interface ToExtensionExecuteExcludingHiddenColumns extends ToExtensionCommandMessage { +interface ToExtensionExecuteRunnerExcludingHiddenColumnsMessage extends ToExtensionCommandMessage { command: 'executeRunner'; value: { type: 'excludingHiddenColumns'; @@ -37,14 +44,43 @@ interface ToExtensionExecuteExcludingHiddenColumns extends ToExtensionCommandMes }; } +export type ExecuteRunnerAllEntry = + | { + type: 'excludingHiddenColumns'; + entry: defaultTypes.HistoryEntry; + hiddenColumns: string[]; + } + | { type: 'default'; entry: defaultTypes.HistoryEntry }; + +export interface ToExtensionExecuteAllFutureRunnerMessage extends ToExtensionCommandMessage { + command: 'executeRunnerAllFuture'; + value: { + futureEntries: ExecuteRunnerAllEntry[]; + pastEntries: defaultTypes.HistoryEntry[]; + jumpedToHistoryId: number; + }; +} + +interface ToExtensionExecuteAllRunnerMessage extends ToExtensionCommandMessage { + command: 'executeRunnerAll'; + value: { entries: ExecuteRunnerAllEntry[]; jumpedToHistoryId: number }; +} + export type ToExtensionMessage = | ToExtensionSetInfoMessage | ToExtensionSetErrorMessage | ToExtensionExecuteRunnerMessage - | ToExtensionExecuteExcludingHiddenColumns; + | ToExtensionExecuteRunnerExcludingHiddenColumnsMessage + | ToExtensionExecuteAllRunnerMessage + | ToExtensionExecuteAllFutureRunnerMessage; // From extension -type FromExtensionCommand = 'setInitialTable' | 'setProfiling' | 'runnerExecutionResult' | 'cancelRunnerExecution'; +type FromExtensionCommand = + | 'setInitialTable' + | 'setProfiling' + | 'runnerExecutionResult' + | 'multipleRunnerExecutionResult' + | 'cancelRunnerExecution'; interface FromExtensionCommandMessage { command: FromExtensionCommand; @@ -85,6 +121,15 @@ export interface RunnerExecutionResultMessage extends FromExtensionCommandMessag value: RunnerExecutionResultTab | RunnerExecutionResultTable | RunnerExecutionResultProfiling; } +export interface MultipleRunnerExecutionResultMessage extends FromExtensionCommandMessage { + command: 'multipleRunnerExecutionResult'; + value: { + type: 'future' | 'past'; + results: (RunnerExecutionResultTab | RunnerExecutionResultTable | RunnerExecutionResultProfiling)[]; + jumpedToHistoryId: number; + }; +} + export interface CancelRunnerExecutionMessage extends FromExtensionCommandMessage { command: 'cancelRunnerExecution'; value: defaultTypes.HistoryEntry; @@ -94,4 +139,5 @@ export type FromExtensionMessage = | FromExtensionSetInitialTableMessage | FromExtensionSetProfilingMessage | RunnerExecutionResultMessage - | CancelRunnerExecutionMessage; + | CancelRunnerExecutionMessage + | MultipleRunnerExecutionResultMessage; diff --git a/packages/safe-ds-eda/types/state.ts b/packages/safe-ds-eda/types/state.ts index c87689821..bef3b0f93 100644 --- a/packages/safe-ds-eda/types/state.ts +++ b/packages/safe-ds-eda/types/state.ts @@ -7,6 +7,7 @@ interface HistoryEntryBase { type: 'internal' | 'external-manipulating' | 'external-visualizing'; alias: string; action: Action; + loading?: boolean; } export interface InternalHistoryEntryBase extends HistoryEntryBase { @@ -40,11 +41,16 @@ type ExternalVisualizingHistoryEntryBase = | ExternalVisualizingHistoryEntryBaseNew; export interface InternalColumnWithValueHistoryEntry extends InternalHistoryEntryBase { - action: 'reorderColumns' | 'resizeColumn'; + action: 'resizeColumn'; columnName: string; value: number; } +export interface InternalColumnReoderHistoryEntry extends InternalHistoryEntryBase { + action: 'reorderColumns'; + columnOrder: string[]; +} + export interface InternalColumnHistoryEntry extends InternalHistoryEntryBase { action: 'hideColumn' | 'highlightColumn' | 'showColumn'; columnName: string; @@ -101,7 +107,8 @@ export type TabHistoryEntry = export type InternalHistoryEntry = | InternalColumnWithValueHistoryEntry | InternalColumnHistoryEntry - | InteralEmptyTabHistoryEntry; + | InteralEmptyTabHistoryEntry + | InternalColumnReoderHistoryEntry; export type ExternalManipulatingHistoryEntry = | ExternalManipulatingColumnFilterHistoryEntry | ExternalManipulatingTableFilterHistoryEntry @@ -114,10 +121,16 @@ export type ExternalVisualizingHistoryEntry = export type ExternalHistoryEntry = ExternalManipulatingHistoryEntry | ExternalVisualizingHistoryEntry; -export type HistoryEntry = (InternalHistoryEntry | ExternalHistoryEntry) & { +interface ExtendedInfo { id: number; overrideId: string; -}; + tabOrder: string[]; +} + +export type FullExternalVisualizingHistoryEntry = ExternalVisualizingHistoryEntry & ExtendedInfo; +export type FullExternalManipulatingHistoryEntry = ExternalManipulatingHistoryEntry & ExtendedInfo; +export type FullInternalHistoryEntry = InternalHistoryEntry & ExtendedInfo; +export type HistoryEntry = (InternalHistoryEntry | ExternalHistoryEntry) & ExtendedInfo; // ------------------ Types for the Tabs ------------------ export type TwoColumnTabTypes = 'linePlot' | 'scatterPlot'; diff --git a/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts b/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts index dfbfc94d6..92ddf5599 100644 --- a/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts +++ b/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts @@ -14,7 +14,11 @@ import * as vscode from 'vscode'; import crypto from 'crypto'; import { getPipelineDocument } from '../../mainClient.ts'; import { safeDsLogger } from '../../helpers/logging.js'; -import { RunnerExecutionResultMessage } from '@safe-ds/eda/types/messaging.ts'; +import { + ExecuteRunnerAllEntry, + MultipleRunnerExecutionResultMessage, + RunnerExecutionResultMessage, +} from '@safe-ds/eda/types/messaging.ts'; export class RunnerApi { services: SafeDsServices; @@ -165,7 +169,7 @@ export class RunnerApi { overrideTablePlaceholder?: string, ): { sdsString: string; - placeholderNames: string[]; + placeholderName: string; } { const newPlaceholderName = this.genPlaceholderName(); switch (historyEntry.action) { @@ -176,7 +180,7 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'boxPlot': return { @@ -185,7 +189,7 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'linePlot': return { @@ -195,7 +199,7 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'scatterPlot': return { @@ -205,7 +209,7 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'heatmap': return { @@ -213,7 +217,7 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'sortByColumn': return { @@ -223,13 +227,13 @@ export class RunnerApi { overrideTablePlaceholder ?? this.tablePlaceholder, newPlaceholderName, ), - placeholderNames: [newPlaceholderName], + placeholderName: newPlaceholderName, }; case 'voidSortByColumn': // This is a void action, no SDS code is generated and new placeholder is just previous one return { sdsString: '', - placeholderNames: [overrideTablePlaceholder ?? this.tablePlaceholder], + placeholderName: overrideTablePlaceholder ?? this.tablePlaceholder, }; default: throw new Error('Unknown history entry action: ' + historyEntry.action); @@ -668,7 +672,7 @@ export class RunnerApi { hiddenColumns?: string[], ): Promise { let sdsLines = ''; - let placeholderNames: string[] = []; + let placeholderNameNeeded: string | undefined; let currentPlaceholderOverride = this.tablePlaceholder; // let schemaPlaceHolder = this.genPlaceholderName('schema'); @@ -679,7 +683,7 @@ export class RunnerApi { // Only manipulating actions have to be repeated before last entry that is of interest, others do not influence that end result const sdsStringObj = this.sdsStringForHistoryEntry(entry, currentPlaceholderOverride); sdsLines += sdsStringObj.sdsString; - currentPlaceholderOverride = sdsStringObj.placeholderNames[0]!; + currentPlaceholderOverride = sdsStringObj.placeholderName; safeDsLogger.debug(`Running old entry ${entry.id} with action ${entry.action}`); } } @@ -702,13 +706,13 @@ export class RunnerApi { overriddenTablePlaceholder ?? currentPlaceholderOverride, ); sdsLines += sdsStringObj.sdsString; - placeholderNames = sdsStringObj.placeholderNames; + placeholderNameNeeded = sdsStringObj.placeholderName; safeDsLogger.debug(`Running new entry ${newEntry.id} with action ${newEntry.action}`); } else if (newEntry.type === 'external-manipulating') { const sdsStringObj = this.sdsStringForHistoryEntry(newEntry, currentPlaceholderOverride); sdsLines += sdsStringObj.sdsString; - placeholderNames = sdsStringObj.placeholderNames; + placeholderNameNeeded = sdsStringObj.placeholderName; safeDsLogger.debug(`Running new entry ${newEntry.id} with action ${newEntry.action}`); } else if (newEntry.type === 'internal') { @@ -720,19 +724,14 @@ export class RunnerApi { await this.addToAndExecutePipeline( pipelineExecutionId, sdsLines, - // placeholderNames.concat(schemaPlaceHolder), - placeholderNames, + placeholderNameNeeded ? [placeholderNameNeeded] : undefined, ); } catch (e) { throw e; } - if ( - newEntry.type === 'external-visualizing' && - newEntry.action !== 'infoPanel' && - placeholderNames.length > 0 - ) { - const result = await this.getPlaceholderValue(placeholderNames[0]!, pipelineExecutionId); + if (newEntry.type === 'external-visualizing' && newEntry.action !== 'infoPanel' && placeholderNameNeeded) { + const result = await this.getPlaceholderValue(placeholderNameNeeded, pipelineExecutionId); const image = result as Base64Image; if (newEntry.columnNumber === 'none') { @@ -785,8 +784,8 @@ export class RunnerApi { }, }; } - } else { - const newTable = await this.getPlaceholderValue(placeholderNames[0]!, pipelineExecutionId); + } else if (placeholderNameNeeded) { + const newTable = await this.getPlaceholderValue(placeholderNameNeeded, pipelineExecutionId); // const schema = await this.getPlaceholderValue(schemaPlaceHolder, pipelineExecutionId); // Not displayable yet, waiting if (!newTable) throw new Error('Table not found'); @@ -802,9 +801,183 @@ export class RunnerApi { ), // temp until schema works as otherwise we would need another execution to get column names ), }; + } else { + throw new Error('placeholderNameNeeded not found'); } } + public async executeMultipleHistoryAndReturnNewResults( + entries: ExecuteRunnerAllEntry[], + placeholderOverride = this.tablePlaceholder, + sdsLinesOverride = '', + ): Promise { + let sdsLines = sdsLinesOverride; + let placeholderNames: string[] = []; + let entryIdToPlaceholderNames = new Map(); + let currentPlaceholderOverride = placeholderOverride; + // let schemaPlaceHolder = this.genPlaceholderName('schema'); + + const filteredEntries: ExecuteRunnerAllEntry[] = this.filterPastEntriesForAllExecution(entries); + + const results: RunnerExecutionResultMessage['value'][] = []; + let lastManipulatingEntry: ExecuteRunnerAllEntry | undefined; + for (const entry of filteredEntries) { + if (entry.entry.type === 'external-visualizing') { + if (entry.entry.action === 'infoPanel') throw new Error('Not implemented'); + + let overriddenTablePlaceholder; + if (entry.type === 'excludingHiddenColumns' && entry.hiddenColumns.length > 0) { + overriddenTablePlaceholder = this.genPlaceholderName('hiddenColsOverride'); + sdsLines += this.sdsStringForRemoveColumns( + entry.hiddenColumns, + currentPlaceholderOverride, + overriddenTablePlaceholder, + ); + } + + const sdsStringObj = this.sdsStringForHistoryEntry( + entry.entry, + overriddenTablePlaceholder ?? currentPlaceholderOverride, + ); + sdsLines += sdsStringObj.sdsString; + placeholderNames.push(sdsStringObj.placeholderName); + entryIdToPlaceholderNames.set(entry.entry.id, sdsStringObj.placeholderName); + + safeDsLogger.debug(`Running new entry ${entry.entry.id} with action ${entry.entry.action}`); + } else if (entry.entry.type === 'external-manipulating') { + const sdsStringObj = this.sdsStringForHistoryEntry(entry.entry, currentPlaceholderOverride); + sdsLines += sdsStringObj.sdsString; + placeholderNames.push(sdsStringObj.placeholderName); + entryIdToPlaceholderNames.set(entry.entry.id, sdsStringObj.placeholderName); + currentPlaceholderOverride = sdsStringObj.placeholderName; + lastManipulatingEntry = entry; + + safeDsLogger.debug(`Running new entry ${entry.entry.id} with action ${entry.entry.action}`); + } + } + + const pipelineExecutionId = crypto.randomUUID(); + try { + await this.addToAndExecutePipeline(pipelineExecutionId, sdsLines, placeholderNames); + } catch (e) { + throw e; + } + + for (const entry of filteredEntries) { + if (entry.entry.type === 'external-visualizing' && entry.entry.action !== 'infoPanel') { + const result = await this.getPlaceholderValue( + entryIdToPlaceholderNames.get(entry.entry.id)!, + pipelineExecutionId, + ); + const image = result as Base64Image; + + if (entry.entry.columnNumber === 'none') { + results.push({ + type: 'tab', + historyId: entry.entry.id, + content: { + tabComment: '', + type: entry.entry.action, + columnNumber: entry.entry.columnNumber, + imageTab: true, + isInGeneration: false, + id: entry.entry.existingTabId ?? entry.entry.newTabId, + content: { encodedImage: image }, + outdated: false, + }, + }); + } else if (entry.entry.columnNumber === 'two') { + results.push({ + type: 'tab', + historyId: entry.entry.id, + content: { + tabComment: entry.entry.xAxisColumnName + ' x ' + entry.entry.yAxisColumnName, + type: entry.entry.action, + columnNumber: entry.entry.columnNumber, + imageTab: true, + isInGeneration: false, + id: entry.entry.existingTabId ?? entry.entry.newTabId, + outdated: false, + content: { + encodedImage: image, + xAxisColumnName: entry.entry.xAxisColumnName, + yAxisColumnName: entry.entry.yAxisColumnName, + }, + }, + }); + } else { + results.push({ + type: 'tab', + historyId: entry.entry.id, + content: { + tabComment: entry.entry.columnName, + type: entry.entry.action, + columnNumber: entry.entry.columnNumber, + imageTab: true, + isInGeneration: false, + id: entry.entry.existingTabId ?? entry.entry.newTabId, + content: { encodedImage: image, columnName: entry.entry.columnName }, + outdated: false, + }, + }); + } + } else if (entry.entry.type === 'external-manipulating') { + if (lastManipulatingEntry?.entry.id !== entry.entry.id) continue; // Only last manipulating entry is of interest as we just need final table + + const newTable = await this.getPlaceholderValue( + entryIdToPlaceholderNames.get(entry.entry.id)!, + pipelineExecutionId, + ); + + if (!newTable) throw new Error('Table not found'); + + results.push({ + type: 'table', + historyId: entry.entry.id, + content: this.runnerResultToTable( + this.tablePlaceholder, + newTable, + new Map( + Object.keys(newTable).map((col) => [col, typeof newTable[col][0] === 'number']), + ), // temp until schema works as otherwise we would need another execution to get column names + ), + }); + } + } + + return results; + } + + public async executeFutureHistoryAndReturnNewResults( + pastEntries: HistoryEntry[], + futureEntries: ExecuteRunnerAllEntry[], + ): Promise { + let sdsLines = ''; + let currentPlaceholderOverride = this.tablePlaceholder; + // let schemaPlaceHolder = this.genPlaceholderName('schema'); + + const { pastEntries: filteredPastEntries, futureEntries: filteredFutureEntries } = + this.filterPastEntriesForMultipleExecution(pastEntries, futureEntries); + + for (const entry of filteredPastEntries) { + if (entry.type === 'external-manipulating') { + // Only manipulating actions have to be repeated before last entry that is of interest, others do not influence that end result + const sdsStringObj = this.sdsStringForHistoryEntry(entry, currentPlaceholderOverride); + sdsLines += sdsStringObj.sdsString; + currentPlaceholderOverride = sdsStringObj.placeholderName; + safeDsLogger.debug(`Running old entry ${entry.id} with action ${entry.action}`); + } + } + + const results = await this.executeMultipleHistoryAndReturnNewResults( + filteredFutureEntries, + currentPlaceholderOverride, + sdsLines, + ); + + return results; + } + filterPastEntries(pastEntries: HistoryEntry[], newEntry: HistoryEntry): HistoryEntry[] { // Keep only the last occurrence of each unique overrideId const lastOccurrenceMap = new Map(); @@ -835,6 +1008,85 @@ export class RunnerApi { return filteredPastEntries; } + + filterPastEntriesForAllExecution(entries: ExecuteRunnerAllEntry[]): ExecuteRunnerAllEntry[] { + // Keep only the last occurrence of each unique overrideId + const lastOccurrenceMap = new Map(); + const filteredPastEntries: ExecuteRunnerAllEntry[] = []; + + // Traverse from end to start to record the last occurrence of each unique overrideId + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]!; + const overrideId = entry.entry.overrideId; + + if (!lastOccurrenceMap.has(overrideId)) { + lastOccurrenceMap.set(overrideId, i); + } + } + + // Traverse from start to end to build the final result with only the last occurrences + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const overrideId = entry.entry.overrideId; + + if (lastOccurrenceMap.get(overrideId) === i) { + filteredPastEntries.push(entry); + } + } + + return filteredPastEntries; + } + + filterPastEntriesForMultipleExecution( + pastEntries: HistoryEntry[], + futureEntries: ExecuteRunnerAllEntry[], + ): { pastEntries: HistoryEntry[]; futureEntries: ExecuteRunnerAllEntry[] } { + // Keep only the last occurrence of each unique overrideId + const lastOccurrenceMap = new Map(); + const filteredPastEntries: HistoryEntry[] = []; + const filteredFutureEntries: ExecuteRunnerAllEntry[] = []; + + // Traverse from end to start to record the last occurrence of each unique overrideId + for (let i = pastEntries.length + futureEntries.length - 1; i >= 0; i--) { + if (i >= pastEntries.length) { + const entry = futureEntries[i - pastEntries.length]!; + const overrideId = entry.entry.overrideId; + + if (!lastOccurrenceMap.has(overrideId)) { + lastOccurrenceMap.set(overrideId, i); + } + } else { + const entry = pastEntries[i]!; + const overrideId = entry.overrideId; + + if (!lastOccurrenceMap.has(overrideId)) { + lastOccurrenceMap.set(overrideId, i); + } + } + } + + // Traverse from start to end to build the final result with only the last occurrences + for (let i = 0; i < pastEntries.length; i++) { + const entry = pastEntries[i]!; + const overrideId = entry.overrideId; + + if (lastOccurrenceMap.get(overrideId) === i) { + filteredPastEntries.push(entry); + } + } + + for (let i = 0; i < futureEntries.length; i++) { + const entry = futureEntries[i]!; + const overrideId = entry.entry.overrideId; + + if (lastOccurrenceMap.get(overrideId) === i + pastEntries.length) { + filteredFutureEntries.push(entry); + } + } + + return { pastEntries: filteredPastEntries, futureEntries: filteredFutureEntries }; + } + //#endregion //#endregion // Public API } diff --git a/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts b/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts index 162b28efb..09f8cbf8a 100644 --- a/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts +++ b/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts @@ -133,6 +133,91 @@ export class EDAPanel { }); break; } + case 'executeRunnerAll': { + if (!data.value) { + return; + } + + let alreadyComplete = false; + + // Execute the runner + const jumpedToHistoryId = data.value.jumpedToHistoryId; + const resultPromise = this.runnerApi.executeMultipleHistoryAndReturnNewResults(data.value.entries); + + // Check if execution takes longer than 1s to show progress indicator + setTimeout(() => { + if (!alreadyComplete) { + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Executing action(s) ...', + }, + async () => { + // Wait for the result to finish in case it's still running + await resultPromise; + alreadyComplete = true; // Mark completion to prevent multiple indicators + }, + ); + } + }, 1000); + + const results = await resultPromise; + alreadyComplete = true; + + webviewApi.postMessage(this.panel.webview, { + command: 'multipleRunnerExecutionResult', + value: { + type: 'past', + results, + jumpedToHistoryId, + }, + }); + break; + } + case 'executeRunnerAllFuture': { + if (!data.value) { + return; + } + + let alreadyComplete = false; + + // Execute the runner + const jumpedToHistoryId = data.value.jumpedToHistoryId; + const resultPromise = this.runnerApi.executeFutureHistoryAndReturnNewResults( + data.value.pastEntries, + data.value.futureEntries, + ); + + // Check if execution takes longer than 1s to show progress indicator + setTimeout(() => { + if (!alreadyComplete) { + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Executing action(s) ...', + }, + async () => { + // Wait for the result to finish in case it's still running + await resultPromise; + alreadyComplete = true; // Mark completion to prevent multiple indicators + }, + ); + } + }, 1000); + + const results = await resultPromise; + alreadyComplete = true; + + webviewApi.postMessage(this.panel.webview, { + command: 'multipleRunnerExecutionResult', + value: { + type: 'future', + results, + jumpedToHistoryId, + }, + }); + break; + } } }); this.disposables.push(this.webviewListener);