From 403cc25a1d6a6c9f9dc7e67157d009f1a9e4dedf Mon Sep 17 00:00:00 2001 From: Jonas B Date: Tue, 18 Jun 2024 16:10:27 +0200 Subject: [PATCH 01/21] fix: profiling scrollbar --- packages/safe-ds-eda/src/components/TableView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-ds-eda/src/components/TableView.svelte b/packages/safe-ds-eda/src/components/TableView.svelte index ac217c889..604af8f9e 100644 --- a/packages/safe-ds-eda/src/components/TableView.svelte +++ b/packages/safe-ds-eda/src/components/TableView.svelte @@ -1315,7 +1315,7 @@ } .profiling .content.expanded { - overflow-y: scroll; + overflow-y: auto; max-height: 500px; opacity: 1; transition: From eac6debaa2bd37315573f95c6324435070a2aa58 Mon Sep 17 00:00:00 2001 From: Jonas B Date: Tue, 18 Jun 2024 16:35:42 +0200 Subject: [PATCH 02/21] feat: show history --- .../safe-ds-eda/src/components/History.svelte | 31 +++++++++++ .../safe-ds-eda/src/components/Sidebar.svelte | 54 +++++++++++++------ 2 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 packages/safe-ds-eda/src/components/History.svelte 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..e2c5900a9 --- /dev/null +++ b/packages/safe-ds-eda/src/components/History.svelte @@ -0,0 +1,31 @@ + + +
+ {#if $history.length === 0} + No history + {/if} + {#each $history as historyItem, index} + {index}. {historyItem.alias} + {/each} +
+ + diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index 5a7edbec1..acc89ab6a 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -7,9 +7,12 @@ import SidebarTab from './tabs/SidebarTab.svelte'; import NewTabButton from './NewTabButton.svelte'; import ColumnCounts from './ColumnCounts.svelte'; + import History from './History.svelte'; export let width: number; + let historyFocused = false; + const changeTab = function (index?: number) { if (!$preventClicks) { currentTabIndex.update((_cs) => index); @@ -28,7 +31,11 @@ {#if width > 50}
- (historyFocused = !historyFocused)} >{#if width > 200}History{/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} {#if width > 109} @@ -124,6 +141,11 @@ cursor: pointer; } + .historyFocused { + font-weight: bold; + font-size: 1.13rem; + } + .titleBar { display: flex; flex-direction: row; From 00967debbb8092e9967b2244c6deefdab0b6d672 Mon Sep 17 00:00:00 2001 From: Jonas B Date: Tue, 18 Jun 2024 16:48:30 +0200 Subject: [PATCH 03/21] fix: reorder history and holding still --- .../src/components/TableView.svelte | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/safe-ds-eda/src/components/TableView.svelte b/packages/safe-ds-eda/src/components/TableView.svelte index 604af8f9e..544a205cb 100644 --- a/packages/safe-ds-eda/src/components/TableView.svelte +++ b/packages/safe-ds-eda/src/components/TableView.svelte @@ -122,7 +122,7 @@ isClick = true; // Assume it's a click initially - holdTimeout = setTimeout(() => handleReorderDragStart(columnIndex), 300); // milliseconds delay for hold detection + holdTimeout = setTimeout(() => handleReorderDragStart(columnIndex, event), 300); // milliseconds delay for hold detection // Define the handler function currentMouseUpHandler = (mouseUpEvent: MouseEvent) => { @@ -192,7 +192,7 @@ let reorderPrototype: HTMLElement; let savedColumnWidthBeforeReorder = 0; - const handleReorderDragStart = function (columnIndex: number): void { + const handleReorderDragStart = function (columnIndex: number, event: MouseEvent): void { isClick = false; // If timeout completes, it's a hold document.addEventListener('mouseup', handleReorderDragEnd); savedColumnWidthBeforeReorder = get(savedColumnWidths).get(headerElements[columnIndex].innerText.trim())!; @@ -201,6 +201,13 @@ dragStartIndex = columnIndex; dragCurrentIndex = columnIndex; selectedColumnIndexes = []; // Clear so reordering doesn't interfere with selection + + // First iteration in case user holds still + dragCurrentIndex = columnIndex; + requestAnimationFrame(() => { + reorderPrototype!.style.left = event.clientX + tableContainer.scrollLeft - sidebarWidth + 'px'; + reorderPrototype!.style.top = event.clientY + scrollTop + 'px'; + }); }; const handleReorderDragOver = function (event: MouseEvent, columnIndex: number): void { @@ -231,12 +238,22 @@ if (dragCurrentIndex > dragStartIndex) { dragCurrentIndex -= 1; } + + addInternalToHistory({ + action: 'reorderColumns', + alias: `Reorder column ${$table!.columns[dragStartIndex!].name}`, + type: 'internal', + columnName: $table!.columns[dragStartIndex!].name, + value: dragCurrentIndex, + }); + table.update(($table) => { const newColumns = [...$table!.columns]; const movedItem = newColumns.splice(dragStartIndex!, 1)[0]; newColumns.splice(dragCurrentIndex!, 0, movedItem); return { ...$table!, columns: newColumns }; }); + document.removeEventListener('mouseup', handleReorderDragEnd); isReorderDragging = false; dragStartIndex = null; From 2de520559d3d50dd34aaff7b358dc3b18df219c4 Mon Sep 17 00:00:00 2001 From: Jonas B Date: Wed, 19 Jun 2024 01:51:38 +0200 Subject: [PATCH 04/21] feat: basic history with undones (no redones yet) --- packages/safe-ds-eda/src/App.svelte | 22 +- packages/safe-ds-eda/src/apis/extensionApi.ts | 29 ++ packages/safe-ds-eda/src/apis/historyApi.ts | 410 ++++++++++++++++-- .../safe-ds-eda/src/components/History.svelte | 38 +- .../safe-ds-eda/src/components/Sidebar.svelte | 5 +- .../src/components/TableView.svelte | 35 +- packages/safe-ds-eda/src/webviewState.ts | 72 ++- packages/safe-ds-eda/types/messaging.ts | 44 +- packages/safe-ds-eda/types/state.ts | 21 +- .../src/extension/eda/apis/runnerApi.ts | 215 ++++++++- .../src/extension/eda/edaPanel.ts | 40 ++ 11 files changed, 813 insertions(+), 118 deletions(-) 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..6a7c24723 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 { filterHistory } from './historyApi'; +import type { ExecuteRunnerAllEntry } from '../../types/messaging'; export const createInfoToast = function (message: string) { window.injVscode.postMessage({ command: 'setInfo', value: message }); @@ -21,6 +23,33 @@ 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: filterHistory(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 }, + }); +}; + 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..2d8b25aac 100644 --- a/packages/safe-ds-eda/src/apis/historyApi.ts +++ b/packages/safe-ds-eda/src/apis/historyApi.ts @@ -1,9 +1,10 @@ -import { get } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import type { FromExtensionMessage, RunnerExecutionResultMessage } from '../../types/messaging'; import type { CategoricalFilter, EmptyTab, ExternalHistoryEntry, + FullInternalHistoryEntry, HistoryEntry, InteralEmptyTabHistoryEntry, InternalHistoryEntry, @@ -12,13 +13,24 @@ 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, +} from '../webviewState'; +import { executeRunner, executeRunnerAll } 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 const getAndIncrementEntryId = function (): number { return entryIdCounter++; @@ -28,10 +40,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 +60,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 +82,7 @@ window.addEventListener('message', (event) => { return; } - deployResult(message, asyncQueue[0]); + deployResult(message.value, asyncQueue[0]); asyncQueue.shift(); if (asyncQueue.length === 0) { @@ -78,17 +92,86 @@ 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) { + const results = message.value.results; + const currentHistory = get(history); + restoreTableInitialState(); + + // Only deploy if the last message is the one that was jumped to + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const entry = currentHistory.find((e) => e.id === 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); + } + + // Redo all internal history things considering overrideIds + 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; + } + } + + 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 + 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); + } + 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; }); @@ -97,19 +180,24 @@ export const addInternalToHistory = function (entry: InternalHistoryEntry): void 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); + overrideUndoneEntries(); history.update((state) => { const entryWithId: HistoryEntry = { ...entry, id: getAndIncrementEntryId(), overrideId: generateOverrideId(entry), + loading: true, + tabOrder: generateTabOrder(), // Not including new entry, but have to update in deploy }; const newHistory = [...state, entryWithId]; + currentHistoryIndex.set(newHistory.length - 1); asyncQueue.push(entryWithId); executeRunner(state, entryWithId); @@ -134,13 +222,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)); }; @@ -159,13 +250,16 @@ 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)); }; @@ -208,6 +302,87 @@ export const setTabAsGenerating = function (tab: RealTab): void { }); }; +export const undoHistoryEntries = function (upToHistoryId: number): void { + const currentHistory = get(history); + const lastRelevantEntry = currentHistory.find((entry) => entry.id === upToHistoryId)!; + const lastRelevantEntryIndex = currentHistory.indexOf(lastRelevantEntry); + + 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); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Could not cancel entry', e); + } + } + } + + // If the remaining entries 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 (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 decrease currentHistoryIndex + 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) { + restoreTableInitialState(); + currentHistoryIndex.set(-1); + currentTabIndex.set(undefined); + return; + } + + const beforeLastEntry = currentHistory[currentHistoryIndexValue - 1]; + undoHistoryEntries(beforeLastEntry.id); +}; + export const unsetTabAsGenerating = function (tab: RealTab): void { tabs.update((state) => { const newTabs = state.map((t) => { @@ -228,8 +403,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,48 +423,86 @@ const deployResult = function (result: RunnerExecutionResultMessage, historyEntr } }), ); - currentTabIndex.set(tabIndex); - return; + if (updateFocusedTab) currentTabIndex.set(tabIndex); + } else { + // eslint-disable-next-line no-console + console.error('Existing tab not found in tabs'); + + 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, + }; }); + if (updateFocusedTab) currentTabIndex.set(undefined); + updateTabOutdated(historyEntry); } + + // 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; + } + }); + }); + } }; const evaluateMessagesWaitingForTurn = function () { @@ -297,7 +513,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) { @@ -332,3 +548,113 @@ const updateTabOutdated = function (entry: ExternalHistoryEntry | InternalHistor }); } }; + +export const filterHistory = 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 = filterHistory(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 { + ...state!, + columns: newColumns, + }; + }); + 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 { + ...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; +}; diff --git a/packages/safe-ds-eda/src/components/History.svelte b/packages/safe-ds-eda/src/components/History.svelte index e2c5900a9..8f536eb73 100644 --- a/packages/safe-ds-eda/src/components/History.svelte +++ b/packages/safe-ds-eda/src/components/History.svelte @@ -1,4 +1,5 @@ @@ -7,7 +8,17 @@ No history {/if} {#each $history as historyItem, index} - {index}. {historyItem.alias} + ($currentHistoryIndex > index ? undoHistoryEntries(historyItem.id) : undefined)} + > + {index + 1}. {historyItem.alias} + {#if historyItem.loading} + + {/if} + {/each} @@ -27,5 +38,30 @@ cursor: pointer; color: var(--darkest-color); font-size: 1.1em; + display: flex; + align-items: center; + } + + .inactiveItem { + color: var(--medium-color); + } + + .spinner { + margin-left: 10px; + width: 16px; + height: 16px; + border: 2px solid rgba(0, 0, 0, 0.1); + border-top: 2px solid var(--darkest-color); + border-radius: 50%; + animation: spin 0.6s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index acc89ab6a..b6993891a 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ @@ -10,11 +10,17 @@ {#each $history as historyItem, index} ($currentHistoryIndex > index ? undoHistoryEntries(historyItem.id) : undefined)} + on:click={() => + $currentHistoryIndex > index + ? undoHistoryEntries(historyItem.id) + : $currentHistoryIndex < index + ? redoHistoryEntries(historyItem.id) + : null} > - {index + 1}. {historyItem.alias} + {index + 1}. {historyItem.alias} {#if historyItem.loading} {/if} @@ -37,9 +43,24 @@ .historyItem { cursor: pointer; color: var(--darkest-color); - font-size: 1.1em; + font-size: 1.15em; display: flex; align-items: center; + overflow: hidden; + } + + .historyItem:hover * { + color: var(--dark-color); + } + + .historyText { + display: -webkit-box; + -webkit-line-clamp: 2; /* Number of lines to show */ + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-wrap: break-word; /* Ensures long words break correctly */ + max-width: calc(100% - 26px); /* Account for the spinner width */ } .inactiveItem { diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index b6993891a..2553a0feb 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -8,7 +8,14 @@ import NewTabButton from './NewTabButton.svelte'; import ColumnCounts from './ColumnCounts.svelte'; import History from './History.svelte'; - import { undoLastHistoryEntry } from '../apis/historyApi'; + import { + getRedoEntry, + getUndoEntry, + redoEntry, + redoLastHistoryEntry, + undoEntry, + undoLastHistoryEntry, + } from '../apis/historyApi'; export let width: number; @@ -37,13 +44,28 @@ class:historyFocused role="none" on:click={() => (historyFocused = !historyFocused)} - >{#if width > 200}History{/if}{#if width > 200}History{/if} - undoLastHistoryEntry()} - >{#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} @@ -71,7 +93,7 @@ {/if} - {:else} + {:else if width > 150}
@@ -249,6 +271,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 6113a93fb..9ea66bfc2 100644 --- a/packages/safe-ds-eda/src/components/TableView.svelte +++ b/packages/safe-ds-eda/src/components/TableView.svelte @@ -558,7 +558,7 @@ executeExternalHistoryEntry({ action: type, - alias: `View ${type === 'linePlot' ? 'Lineplot' : 'Scatterplot'} of ${xAxisColumnName} x ${yAxisColumnName}`, + alias: `${type === 'linePlot' ? 'Lineplot' : 'Scatterplot'} for ${xAxisColumnName} x ${yAxisColumnName}`, type: 'external-visualizing', columnNumber: 'two', xAxisColumnName, @@ -576,7 +576,7 @@ executeExternalHistoryEntry({ action: type, - alias: `View ${type === 'histogram' ? 'Histogram' : 'Boxplot'} of ${columnName}`, + alias: `${type === 'histogram' ? 'Histogram' : 'Boxplot'} for ${columnName}`, type: 'external-visualizing', columnNumber: 'one', columnName, diff --git a/packages/safe-ds-eda/src/components/profiling/ProfilingInfo.svelte b/packages/safe-ds-eda/src/components/profiling/ProfilingInfo.svelte index 95ba0bbe9..325dc8bc4 100644 --- a/packages/safe-ds-eda/src/components/profiling/ProfilingInfo.svelte +++ b/packages/safe-ds-eda/src/components/profiling/ProfilingInfo.svelte @@ -15,7 +15,7 @@ addAndDeployTabHistoryEntry( { action: 'histogram', - alias: `View ${columnName} Histogram`, + alias: `Histogram for ${columnName}`, type: 'external-visualizing', columnName, id: entryId, diff --git a/packages/safe-ds-eda/src/components/tabs/TabContent.svelte b/packages/safe-ds-eda/src/components/tabs/TabContent.svelte index 9f037ab19..381ab12d1 100644 --- a/packages/safe-ds-eda/src/components/tabs/TabContent.svelte +++ b/packages/safe-ds-eda/src/components/tabs/TabContent.svelte @@ -248,7 +248,7 @@ return { existingTabId: tab.id, action: $buildATab.type, - alias: 'Generate ' + getTabName($buildATab) + ' in existing Tab', + alias: getTabName($buildATab) + ' for ' + $buildATab.xAxisColumnName + ' in existing Tab', type: 'external-visualizing', columnName: $buildATab.xAxisColumnName, columnNumber: 'one', @@ -258,7 +258,13 @@ return { existingTabId: tab.id, action: $buildATab.type, - alias: 'Generate ' + getTabName($buildATab) + ' in existing Tab', + alias: + getTabName($buildATab) + + ' for ' + + $buildATab.xAxisColumnName + + ' x ' + + $buildATab.yAxisColumnName + + ' in existing Tab', type: 'external-visualizing', xAxisColumnName: $buildATab.xAxisColumnName, yAxisColumnName: $buildATab.yAxisColumnName, @@ -268,7 +274,7 @@ return { existingTabId: tab.id, action: $buildATab.type, - alias: 'Generate ' + getTabName($buildATab) + ' in existing Tab', + alias: getTabName($buildATab) + ' in existing Tab', type: 'external-visualizing', columnNumber: 'none', }; diff --git a/packages/safe-ds-eda/src/icons/History.svelte b/packages/safe-ds-eda/src/icons/History.svelte index 8ec1da1d1..565984527 100644 --- a/packages/safe-ds-eda/src/icons/History.svelte +++ b/packages/safe-ds-eda/src/icons/History.svelte @@ -1,6 +1,13 @@ - + export let strokeWidth = 20; + + + + + + /> + 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 313f50732..0bd1cf0c8 100644 --- a/packages/safe-ds-eda/src/webviewState.ts +++ b/packages/safe-ds-eda/src/webviewState.ts @@ -21,7 +21,6 @@ const showProfiling = writable(false); const table = writable(); const history = writable([]); - const savedColumnWidths = writable(new Map()); const tableLoading = writable(false); @@ -71,9 +70,12 @@ const restoreTableInitialState = () => { table.set(initialTable); tabs.set([]); setProfiling(initialProfiling); - currentTabIndex.set(undefined); cancelTabIdsWaiting.set([]); savedColumnWidths.set(new Map()); + rerender(); +}; + +const rerender = () => { tableKey.update((key) => key + 1); tabKey.update((key) => key + 1); }; @@ -88,6 +90,7 @@ export { 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 245cc03db..889be1611 100644 --- a/packages/safe-ds-eda/types/messaging.ts +++ b/packages/safe-ds-eda/types/messaging.ts @@ -7,7 +7,8 @@ type ToExtensionCommand = | 'setInfo' | 'setError' | 'executeRunner' - | 'executeRunnerAll'; + | 'executeRunnerAll' + | 'executeRunnerAllFuture'; interface ToExtensionCommandMessage { command: ToExtensionCommand; @@ -51,6 +52,15 @@ export type ExecuteRunnerAllEntry = } | { 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 }; @@ -61,7 +71,8 @@ export type ToExtensionMessage = | ToExtensionSetErrorMessage | ToExtensionExecuteRunnerMessage | ToExtensionExecuteRunnerExcludingHiddenColumnsMessage - | ToExtensionExecuteAllRunnerMessage; + | ToExtensionExecuteAllRunnerMessage + | ToExtensionExecuteAllFutureRunnerMessage; // From extension type FromExtensionCommand = @@ -113,6 +124,7 @@ export interface RunnerExecutionResultMessage extends FromExtensionCommandMessag export interface MultipleRunnerExecutionResultMessage extends FromExtensionCommandMessage { command: 'multipleRunnerExecutionResult'; value: { + type: 'future' | 'past'; results: (RunnerExecutionResultTab | RunnerExecutionResultTable | RunnerExecutionResultProfiling)[]; jumpedToHistoryId: number; }; 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 06331ac23..92ddf5599 100644 --- a/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts +++ b/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts @@ -808,11 +808,13 @@ export class RunnerApi { public async executeMultipleHistoryAndReturnNewResults( entries: ExecuteRunnerAllEntry[], + placeholderOverride = this.tablePlaceholder, + sdsLinesOverride = '', ): Promise { - let sdsLines = ''; + let sdsLines = sdsLinesOverride; let placeholderNames: string[] = []; let entryIdToPlaceholderNames = new Map(); - let currentPlaceholderOverride = this.tablePlaceholder; + let currentPlaceholderOverride = placeholderOverride; // let schemaPlaceHolder = this.genPlaceholderName('schema'); const filteredEntries: ExecuteRunnerAllEntry[] = this.filterPastEntriesForAllExecution(entries); @@ -946,6 +948,36 @@ export class RunnerApi { 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(); @@ -1004,6 +1036,57 @@ export class RunnerApi { 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 419413490..09f8cbf8a 100644 --- a/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts +++ b/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts @@ -150,7 +150,51 @@ export class EDAPanel { vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: 'Executing action ...', + 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 @@ -167,6 +211,7 @@ export class EDAPanel { webviewApi.postMessage(this.panel.webview, { command: 'multipleRunnerExecutionResult', value: { + type: 'future', results, jumpedToHistoryId, }, From 17983b519007218310591a22a683e945aecb543f Mon Sep 17 00:00:00 2001 From: Jonas B Date: Thu, 20 Jun 2024 23:07:33 +0200 Subject: [PATCH 08/21] refactor: linter issues --- packages/safe-ds-eda/src/components/Sidebar.svelte | 11 ++--------- packages/safe-ds-eda/src/components/TableView.svelte | 5 +++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index 2553a0feb..2d173480a 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -1,5 +1,5 @@