diff --git a/.eslintignore b/.eslintignore index b2ebd2b63..e3878766d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,5 +9,6 @@ # Configuration /vitest.config.ts /packages/safe-ds-eda/svelte.config.js +/packages/safe-ds-eda/consts.config.ts /packages/safe-ds-eda/vite.config.ts /packages/safe-ds-eda/types/*.d.ts diff --git a/packages/safe-ds-eda/consts.config.ts b/packages/safe-ds-eda/consts.config.ts new file mode 100644 index 000000000..0dc42f1bf --- /dev/null +++ b/packages/safe-ds-eda/consts.config.ts @@ -0,0 +1 @@ +export const imageWidthToHeightRatio = 1 + 1 / 3; diff --git a/packages/safe-ds-eda/src/App.svelte b/packages/safe-ds-eda/src/App.svelte index 59541c42e..b436865b1 100644 --- a/packages/safe-ds-eda/src/App.svelte +++ b/packages/safe-ds-eda/src/App.svelte @@ -2,6 +2,8 @@ import TableView from './components/TableView.svelte'; import Sidebar from './components/Sidebar.svelte'; import { throttle } from 'lodash'; + import { currentTabIndex, currentState } from './webviewState'; + import TabContent from './components/tabs/TabContent.svelte'; let sidebarWidth = 307; // Initial width of the sidebar in pixels @@ -37,12 +39,21 @@
-
+
-
- +
+
+ +
+ {#if $currentState.tabs} + {#each $currentState.tabs as tab, index} +
+ +
+ {/each} + {/if}
@@ -59,15 +70,17 @@ flex-shrink: 0; overflow: hidden; position: relative; - background-color: var(--bg-bright); + background-color: var(--bg-dark); } - .white-bg { - background-color: var(--bg-bright); + .contentWrapper { + flex: 1; + width: 100%; } - .tableWrapper { - flex: 1; + .contentWrapper * { + height: 100%; + width: 100%; } .resizer { @@ -79,4 +92,8 @@ cursor: ew-resize; background-color: transparent; } + + .hide { + display: none; + } diff --git a/packages/safe-ds-eda/src/apis/extensionApi.ts b/packages/safe-ds-eda/src/apis/extensionApi.ts index 58e4f41a7..883dbe7da 100644 --- a/packages/safe-ds-eda/src/apis/extensionApi.ts +++ b/packages/safe-ds-eda/src/apis/extensionApi.ts @@ -1,18 +1,4 @@ -import type { State } from '../../types/state'; - -export const setCurrentGlobalState = function (state: State) { - window.injVscode.postMessage({ - command: 'setCurrentGlobalState', - value: state, - }); -}; - -export const resetGlobalState = function () { - window.injVscode.postMessage({ - command: 'resetGlobalState', - value: null, - }); -}; +import type { HistoryEntry } from '../../types/state'; export const createInfoToast = function (message: string) { window.injVscode.postMessage({ command: 'setInfo', value: message }); @@ -21,3 +7,7 @@ export const createInfoToast = function (message: string) { export const createErrorToast = function (message: string) { window.injVscode.postMessage({ command: 'setError', value: message }); }; + +export const executeRunner = function (pastEntries: HistoryEntry[], newEntry: HistoryEntry) { + window.injVscode.postMessage({ command: 'executeRunner', value: { pastEntries, newEntry } }); +}; diff --git a/packages/safe-ds-eda/src/apis/historyApi.ts b/packages/safe-ds-eda/src/apis/historyApi.ts new file mode 100644 index 000000000..de723ebd5 --- /dev/null +++ b/packages/safe-ds-eda/src/apis/historyApi.ts @@ -0,0 +1,245 @@ +import { get } from 'svelte/store'; +import type { FromExtensionMessage, RunnerExecutionResultMessage } from '../../types/messaging'; +import type { + EmptyTab, + ExternalHistoryEntry, + HistoryEntry, + InteralEmptyTabHistoryEntry, + InternalHistoryEntry, + RealTab, + Tab, + TabHistoryEntry, +} from '../../types/state'; +import { cancelTabIdsWaiting, currentState, currentTabIndex } from '../webviewState'; +import { executeRunner } from './extensionApi'; + +// Wait for results to return from the server +const asyncQueue: (ExternalHistoryEntry & { id: number })[] = []; +let messagesWaitingForTurn: RunnerExecutionResultMessage[] = []; +let entryIdCounter = 0; + +export const getAndIncrementEntryId = function (): number { + return entryIdCounter++; +}; + +window.addEventListener('message', (event) => { + const message = event.data as FromExtensionMessage; + + if (message.command === 'runnerExecutionResult') { + if (asyncQueue.length === 0) { + throw new Error('No entries in asyncQueue'); + } + const asyncQueueEntryIndex = asyncQueue.findIndex((entry) => entry.id === message.value.historyId); + if (asyncQueueEntryIndex === -1) return; + if (asyncQueueEntryIndex !== 0) { + // eslint-disable-next-line no-console + console.log('Message not in turn, waiting for turn'); + messagesWaitingForTurn.push(message); + return; + } + + deployResult(message); + asyncQueue.shift(); + evaluateMessagesWaitingForTurn(); + } else if (message.command === 'cancelRunnerExecution') { + cancelExecuteExternalHistoryEntry(message.value); + } +}); + +export const addInternalToHistory = function (entry: InternalHistoryEntry): void { + currentState.update((state) => { + const entryWithId: HistoryEntry = { + ...entry, + id: getAndIncrementEntryId(), + }; + const newHistory = [...state.history, entryWithId]; + return { + ...state, + history: newHistory, + }; + }); +}; + +export const executeExternalHistoryEntry = function (entry: ExternalHistoryEntry): void { + currentState.update((state) => { + const entryWithId: HistoryEntry = { + ...entry, + id: getAndIncrementEntryId(), + }; + const newHistory = [...state.history, entryWithId]; + + asyncQueue.push(entryWithId); + executeRunner(state.history, entryWithId); // Is this good in here? Otherwise risk of empty array idk + + return { + ...state, + history: newHistory, + }; + }); +}; + +export const addAndDeployTabHistoryEntry = function (entry: TabHistoryEntry & { id: number }, tab: Tab): void { + // Search if already exists and is up to date + const existingTab = get(currentState).tabs?.find( + (et) => + et.type !== 'empty' && + et.type === tab.type && + et.tabComment === tab.tabComment && + tab.type && + !et.content.outdated && + !et.isInGeneration, + ); + if (existingTab) { + currentTabIndex.set(get(currentState).tabs!.indexOf(existingTab)); + return; + } + + currentState.update((state) => { + const newHistory = [...state.history, entry]; + + return { + ...state, + history: newHistory, + tabs: (state.tabs ?? []).concat([tab]), + }; + }); + currentTabIndex.set(get(currentState).tabs!.indexOf(tab)); +}; + +export const addEmptyTabHistoryEntry = function (): void { + const entry: InteralEmptyTabHistoryEntry & { id: number } = { + action: 'emptyTab', + type: 'internal', + alias: 'New empty tab', + id: getAndIncrementEntryId(), + }; + const tab: EmptyTab = { + type: 'empty', + id: crypto.randomUUID(), + isInGeneration: true, + }; + + currentState.update((state) => { + const newHistory = [...state.history, entry]; + + return { + ...state, + history: newHistory, + tabs: (state.tabs ?? []).concat([tab]), + }; + }); + currentTabIndex.set(get(currentState).tabs!.indexOf(tab)); +}; + +export const cancelExecuteExternalHistoryEntry = function (entry: HistoryEntry): void { + const index = asyncQueue.findIndex((queueEntry) => queueEntry.id === entry.id); + if (index !== -1) { + asyncQueue.splice(index, 1); + if (entry.type === 'external-visualizing' && entry.existingTabId) { + cancelTabIdsWaiting.update((ids) => { + return ids.concat([entry.existingTabId!]); + }); + const tab: RealTab = get(currentState).tabs!.find( + (t) => t.type !== 'empty' && t.id === entry.existingTabId, + )! as RealTab; + unsetTabAsGenerating(tab); + } + } else { + throw new Error('Entry already fully executed'); + } +}; + +export const setTabAsGenerating = function (tab: RealTab): void { + currentState.update((state) => { + const newTabs = state.tabs?.map((t) => { + if (t === tab) { + return { + ...t, + isInGeneration: true, + }; + } else { + return t; + } + }); + + return { + ...state, + tabs: newTabs, + }; + }); +}; + +export const unsetTabAsGenerating = function (tab: RealTab): void { + currentState.update((state) => { + const newTabs = state.tabs?.map((t) => { + if (t === tab) { + return { + ...t, + isInGeneration: false, + }; + } else { + return t; + } + }); + + return { + ...state, + tabs: newTabs, + }; + }); +}; + +const deployResult = function (result: RunnerExecutionResultMessage) { + const resultContent = result.value; + if (resultContent.type === 'tab') { + if (resultContent.content.id) { + const existingTab = get(currentState).tabs?.find((et) => et.id === resultContent.content.id); + if (existingTab) { + const tabIndex = get(currentState).tabs!.indexOf(existingTab); + currentState.update((state) => { + return { + ...state, + tabs: state.tabs?.map((t) => { + if (t.id === resultContent.content.id) { + return resultContent.content; + } else { + return t; + } + }), + }; + }); + currentTabIndex.set(tabIndex); + return; + } + } + const tab = resultContent.content; + tab.id = crypto.randomUUID(); + currentState.update((state) => { + return { + ...state, + tabs: (state.tabs ?? []).concat(tab), + }; + }); + currentTabIndex.set(get(currentState).tabs!.indexOf(tab)); + } +}; + +const evaluateMessagesWaitingForTurn = function () { + const newMessagesWaitingForTurn: RunnerExecutionResultMessage[] = []; + let firstItemQueueChanged = false; + + for (const entry of messagesWaitingForTurn) { + if (asyncQueue[0].id === entry.value.historyId) { + // eslint-disable-next-line no-console + console.log(`Deploying message from waiting queue: ${entry}`); + deployResult(entry); + asyncQueue.shift(); + firstItemQueueChanged = true; + } else if (asyncQueue.findIndex((queueEntry) => queueEntry.id === entry.value.historyId) !== -1) { + newMessagesWaitingForTurn.push(entry); // Only those that still exist in asyncqueue and were not the first item still have to be waited for + } + } + + messagesWaitingForTurn = newMessagesWaitingForTurn; + if (firstItemQueueChanged) evaluateMessagesWaitingForTurn(); // Only if first element was deployed we have to scan again, as this is only deployment condition +}; diff --git a/packages/safe-ds-eda/src/components/NewTabButton.svelte b/packages/safe-ds-eda/src/components/NewTabButton.svelte new file mode 100644 index 000000000..1b8e58acf --- /dev/null +++ b/packages/safe-ds-eda/src/components/NewTabButton.svelte @@ -0,0 +1,25 @@ + + +
+
+ +
+
+ + diff --git a/packages/safe-ds-eda/src/components/Sidebar.svelte b/packages/safe-ds-eda/src/components/Sidebar.svelte index a337c2df8..d5f15e1a2 100644 --- a/packages/safe-ds-eda/src/components/Sidebar.svelte +++ b/packages/safe-ds-eda/src/components/Sidebar.svelte @@ -1,12 +1,19 @@ -
- {#if width > 50} + {#if width > 50} +
{#if width > 200}History{/if} @@ -30,34 +37,35 @@ {#if width > 200}Redo{/if} - {/if} -
+
+ {/if}
{#if width > 50} - + {#if $currentState.tabs} {#each $currentState.tabs as tab, index} - {#if tab.type === 'linePlot'} - - {/if} + {/each} {/if} {/if}
+ {#if width > 50} +
+ +
+ {/if} diff --git a/packages/safe-ds-eda/src/components/tabs/LinePlotTab.svelte b/packages/safe-ds-eda/src/components/tabs/LinePlotTab.svelte deleted file mode 100644 index c0a76d0c6..000000000 --- a/packages/safe-ds-eda/src/components/tabs/LinePlotTab.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - -
-
- - {#if width > 109}Line Plot{/if} -
- {#if width > 300}{tabObject.tabComment}{/if} -
- - diff --git a/packages/safe-ds-eda/src/components/tabs/SidebarTab.svelte b/packages/safe-ds-eda/src/components/tabs/SidebarTab.svelte new file mode 100644 index 000000000..7b306dac5 --- /dev/null +++ b/packages/safe-ds-eda/src/components/tabs/SidebarTab.svelte @@ -0,0 +1,93 @@ + + +
+
+
+ {#if tabObject.isInGeneration} +
+ {:else if tabObject.type === 'linePlot'} + + {:else if tabObject.type === 'boxPlot'} + + {:else if tabObject.type === 'heatmap'} + + {:else if tabObject.type === 'infoPanel'} + + {:else if tabObject.type === 'histogram'} + + {:else if tabObject.type === 'scatterPlot'} + + {/if} +
+ {#if width > 300 || tabObject.isInGeneration || (tabObject.tabComment === '' && width > 109)} + {#if tabObject.isInGeneration} + Generating... + {:else if tabObject.type === 'histogram'} + Histogram + {:else if tabObject.type === 'boxPlot'} + Boxplot + {:else if tabObject.type === 'heatmap'} + Heatmap + {:else if tabObject.type === 'infoPanel'} + Info panel + {:else if tabObject.type === 'linePlot'} + Lineplot + {:else if tabObject.type === 'scatterPlot'} + Scatterplot + {/if} + {/if} +
+ {#if width > 109 && !tabObject.isInGeneration}{tabObject.tabComment}{/if} +
+ + diff --git a/packages/safe-ds-eda/src/components/tabs/TabContent.svelte b/packages/safe-ds-eda/src/components/tabs/TabContent.svelte new file mode 100644 index 000000000..ede378b66 --- /dev/null +++ b/packages/safe-ds-eda/src/components/tabs/TabContent.svelte @@ -0,0 +1,451 @@ + + +
+
+
+
+
+
+ + {#if tab.type !== 'empty' && tab.content.outdated} + Outdated! + {/if} +
+
+
+ {#if $tabInfo.type !== 'empty' && ($tabInfo.columnNumber === 'one' || $tabInfo.columnNumber === 'two')} +
+ {#if $tabInfo.columnNumber === 'one'} + Column +
+ +
+ {:else} + X-Axis +
+ +
+ {/if} +
+ {/if} + {#if $tabInfo.type !== 'empty' && $tabInfo.columnNumber === 'two'} +
+ +
+
+ Y-Axis + +
+ {/if} +
+
+
+ {#if tab.type !== 'empty' && tab.imageTab} +
+ +
+ {/if} + {#if (tab.type === 'empty' || tab.imageTab) && $isInBuildingState} + {#if isLoadingGeneratedTab} +
+

Loading ...

+
+ {:else if $buildATabComplete} +
+ +
+ {:else} +
+

Complete selections

+
+ {/if} + {/if} +
+
+
+
+ + diff --git a/packages/safe-ds-eda/src/components/tabs/content/ImageContent.svelte b/packages/safe-ds-eda/src/components/tabs/content/ImageContent.svelte new file mode 100644 index 000000000..290729e72 --- /dev/null +++ b/packages/safe-ds-eda/src/components/tabs/content/ImageContent.svelte @@ -0,0 +1,25 @@ + + +
+ profiling plot +
+ + diff --git a/packages/safe-ds-eda/src/components/utilities/DropDownButton.svelte b/packages/safe-ds-eda/src/components/utilities/DropDownButton.svelte new file mode 100644 index 000000000..b7e1d6725 --- /dev/null +++ b/packages/safe-ds-eda/src/components/utilities/DropDownButton.svelte @@ -0,0 +1,169 @@ + + +
+ + + {#if isDropdownOpen} + + {/if} +
+ + diff --git a/packages/safe-ds-eda/src/icons/BarPlot.svelte b/packages/safe-ds-eda/src/icons/BarPlot.svelte index f3728e13f..a2af2b7be 100644 --- a/packages/safe-ds-eda/src/icons/BarPlot.svelte +++ b/packages/safe-ds-eda/src/icons/BarPlot.svelte @@ -1,6 +1,25 @@ - - - + + diff --git a/packages/safe-ds-eda/src/icons/BoxPlot.svelte b/packages/safe-ds-eda/src/icons/BoxPlot.svelte new file mode 100644 index 000000000..4db268132 --- /dev/null +++ b/packages/safe-ds-eda/src/icons/BoxPlot.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/safe-ds-eda/src/icons/Heatmap.svelte b/packages/safe-ds-eda/src/icons/Heatmap.svelte new file mode 100644 index 000000000..6a72376a3 --- /dev/null +++ b/packages/safe-ds-eda/src/icons/Heatmap.svelte @@ -0,0 +1,15 @@ + + + chart-heatmap-solid + + + + + + + + + diff --git a/packages/safe-ds-eda/src/icons/LinePlot.svelte b/packages/safe-ds-eda/src/icons/LinePlot.svelte index 7bf240b0d..668c5107e 100644 --- a/packages/safe-ds-eda/src/icons/LinePlot.svelte +++ b/packages/safe-ds-eda/src/icons/LinePlot.svelte @@ -1,6 +1,15 @@ - - + + + chart-line-solid + + + + + + + + diff --git a/packages/safe-ds-eda/src/icons/Plus.svelte b/packages/safe-ds-eda/src/icons/Plus.svelte new file mode 100644 index 000000000..c13ab58ff --- /dev/null +++ b/packages/safe-ds-eda/src/icons/Plus.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/packages/safe-ds-eda/src/icons/ScatterPlot.svelte b/packages/safe-ds-eda/src/icons/ScatterPlot.svelte new file mode 100644 index 000000000..a84328670 --- /dev/null +++ b/packages/safe-ds-eda/src/icons/ScatterPlot.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/safe-ds-eda/src/icons/Swap.svelte b/packages/safe-ds-eda/src/icons/Swap.svelte new file mode 100644 index 000000000..6a06a23f3 --- /dev/null +++ b/packages/safe-ds-eda/src/icons/Swap.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/safe-ds-eda/src/icons/Table.svelte b/packages/safe-ds-eda/src/icons/Table.svelte index 3ff160b6a..4e76656fc 100644 --- a/packages/safe-ds-eda/src/icons/Table.svelte +++ b/packages/safe-ds-eda/src/icons/Table.svelte @@ -1,4 +1,4 @@ - + + + + + diff --git a/packages/safe-ds-eda/src/toggleNonContextMenuEffects.ts b/packages/safe-ds-eda/src/toggleNonContextMenuEffects.ts new file mode 100644 index 000000000..f509e2426 --- /dev/null +++ b/packages/safe-ds-eda/src/toggleNonContextMenuEffects.ts @@ -0,0 +1,47 @@ +const originalHoverStyles = new Map(); +const originalCursorStyles = new Map(); + +export const disableNonContextMenuEffects = function () { + const stylesheets = document.styleSheets; + + for (let i = 0; i < stylesheets.length; i++) { + const rules = stylesheets[i].cssRules; + const ownerNode = stylesheets[i].ownerNode; + if ( + !(ownerNode instanceof Element) || + (ownerNode instanceof Element && ownerNode.id && !ownerNode.id.includes('svelte')) + ) { + // We only care for stylesheets that are svlete generated + continue; + } + + for (let j = 0; j < rules.length; j++) { + // Remove all hover styles and cursor pointer styles from non context menu elements + const rule = rules[j] as CSSStyleRule; + if (rule.selectorText?.includes(':hover') && !rule.selectorText?.includes('contextMenu')) { + // Store the original hover style + originalHoverStyles.set(rule, rule.style.cssText); + // Disable the hover style + rule.style.cssText = ''; + } + if (rule.style?.cursor === 'pointer' && !rule.selectorText?.includes('contextMenu')) { + // Store the original pointer style + originalCursorStyles.set(rule, rule.style.cssText); + // Disable the cursor pointer + rule.style.cursor = 'auto'; + } + } + } +}; + +export const restoreNonContextMenuEffects = function () { + originalHoverStyles.forEach((style, rule) => { + rule.style.cssText = style; + }); + originalHoverStyles.clear(); + + originalCursorStyles.forEach((style, rule) => { + rule.style.cssText = style; + }); + originalCursorStyles.clear(); +}; diff --git a/packages/safe-ds-eda/src/webviewState.ts b/packages/safe-ds-eda/src/webviewState.ts index a19d1459d..24fbf0fc7 100644 --- a/packages/safe-ds-eda/src/webviewState.ts +++ b/packages/safe-ds-eda/src/webviewState.ts @@ -1,21 +1,15 @@ import type { FromExtensionMessage } from '../types/messaging'; import type { State } from '../types/state'; -import * as extensionApi from './apis/extensionApi'; import { get, writable } from 'svelte/store'; -let currentTabIndex = writable(0); +let currentTabIndex = writable(undefined); let preventClicks = writable(false); -// Define the stores, current state to default in case the extension never calls setWebviewState( Shouldn't happen) -let currentState = writable({ tableIdentifier: undefined, history: [], defaultState: true }); +let cancelTabIdsWaiting = writable([]); -// Set Global states whenever updatedAllStates changes -currentState.subscribe(($currentState) => { - if (!$currentState.defaultState) { - extensionApi.setCurrentGlobalState($currentState); - } -}); +// Define the stores, current state to default in case the extension never calls setWebviewState( Shouldn't happen) +const currentState = writable({ tableIdentifier: undefined, history: [], defaultState: true, tabs: [] }); window.addEventListener('message', (event) => { const message = event.data as FromExtensionMessage; @@ -55,4 +49,4 @@ window.addEventListener('message', (event) => { } }); -export { currentState, currentTabIndex, preventClicks }; +export { currentState, currentTabIndex, preventClicks, cancelTabIdsWaiting }; diff --git a/packages/safe-ds-eda/tsconfig.json b/packages/safe-ds-eda/tsconfig.json index 6deff8f67..15ac19ba8 100644 --- a/packages/safe-ds-eda/tsconfig.json +++ b/packages/safe-ds-eda/tsconfig.json @@ -4,5 +4,5 @@ "rootDir": ".", "noEmit": true }, - "include": ["src/**/*", "types/**/*"] + "include": ["src/**/*", "types/**/*", "./*.ts"] } diff --git a/packages/safe-ds-eda/types/messaging.d.ts b/packages/safe-ds-eda/types/messaging.d.ts deleted file mode 100644 index 438c7081e..000000000 --- a/packages/safe-ds-eda/types/messaging.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as defaultTypes from './state'; - -// To extension -type ToExtensionCommand = 'setGlobalState' | 'setInfo' | 'setError'; - -interface ToExtensionCommandMessage { - command: ToExtensionCommand; - value: any; -} -interface ToExtensionSetStateMessage extends ToExtensionCommandMessage { - command: 'setCurrentGlobalState'; - value: defaultTypes.State; -} - -interface ToExtensionResetStateMessage extends ToExtensionCommandMessage { - command: 'resetGlobalState'; - value: null; -} - -// Just example -interface ToExtensionSetInfoMessage extends ToExtensionCommandMessage { - command: 'setInfo'; - value: string; -} - -interface ToExtensionSetErrorMessage extends ToExtensionCommandMessage { - command: 'setError'; - value: string; -} - -export type ToExtensionMessage = - | ToExtensionSetInfoMessage - | ToExtensionSetStateMessage - | ToExtensionResetStateMessage - | ToExtensionSetErrorMessage; - -// From extension -type FromExtensionCommand = 'setWebviewState'; - -interface FromExtensionCommandMessage { - command: FromExtensionCommand; - value: any; -} -interface FromExtensionSetStateMessage extends FromExtensionCommandMessage { - command: 'setWebviewState'; - value: defaultTypes.State; -} - -interface FromExtensionSetProfilingMessage extends FromExtensionCommandMessage { - command: 'setProfiling'; - value: { columnName: string; profiling: defaultTypes.Profiling }[]; -} - -export type FromExtensionMessage = FromExtensionSetStateMessage | FromExtensionSetProfilingMessage; diff --git a/packages/safe-ds-eda/types/messaging.ts b/packages/safe-ds-eda/types/messaging.ts new file mode 100644 index 000000000..7a009310b --- /dev/null +++ b/packages/safe-ds-eda/types/messaging.ts @@ -0,0 +1,85 @@ +import * as defaultTypes from './state.js'; + +// To extension +type ToExtensionCommand = 'setCurrentGlobalState' | 'resetGlobalState' | 'setInfo' | 'setError' | 'executeRunner'; + +interface ToExtensionCommandMessage { + command: ToExtensionCommand; + value: any; +} + +interface ToExtensionSetInfoMessage extends ToExtensionCommandMessage { + command: 'setInfo'; + value: string; +} + +interface ToExtensionSetErrorMessage extends ToExtensionCommandMessage { + command: 'setError'; + value: string; +} + +interface ToExtensionExecuteRunnerMessage extends ToExtensionCommandMessage { + command: 'executeRunner'; + value: { + pastEntries: defaultTypes.HistoryEntry[]; + newEntry: defaultTypes.HistoryEntry; + }; +} + +export type ToExtensionMessage = + | ToExtensionSetInfoMessage + | ToExtensionSetErrorMessage + | ToExtensionExecuteRunnerMessage; + +// From extension +type FromExtensionCommand = 'setWebviewState' | 'setProfiling' | 'runnerExecutionResult' | 'cancelRunnerExecution'; + +interface FromExtensionCommandMessage { + command: FromExtensionCommand; + value: any; +} +interface FromExtensionSetStateMessage extends FromExtensionCommandMessage { + command: 'setWebviewState'; + value: defaultTypes.State; +} + +interface FromExtensionSetProfilingMessage extends FromExtensionCommandMessage { + command: 'setProfiling'; + value: { columnName: string; profiling: defaultTypes.Profiling }[]; +} + +interface RunnerExecutionResultBase { + type: 'tab' | 'table' | 'profiling'; + historyId: number; +} + +interface RunnerExecutionResultTab extends RunnerExecutionResultBase { + type: 'tab'; + content: defaultTypes.Tab; +} + +interface RunnerExecutionResultTable extends RunnerExecutionResultBase { + type: 'table'; + content: defaultTypes.Table; +} + +interface RunnerExecutionResultProfiling extends RunnerExecutionResultBase { + type: 'profiling'; + content: defaultTypes.Profiling; +} + +export interface RunnerExecutionResultMessage extends FromExtensionCommandMessage { + command: 'runnerExecutionResult'; + value: RunnerExecutionResultTab | RunnerExecutionResultTable | RunnerExecutionResultProfiling; +} + +export interface CancelRunnerExecutionMessage extends FromExtensionCommandMessage { + command: 'cancelRunnerExecution'; + value: defaultTypes.HistoryEntry; +} + +export type FromExtensionMessage = + | FromExtensionSetStateMessage + | FromExtensionSetProfilingMessage + | RunnerExecutionResultMessage + | CancelRunnerExecutionMessage; diff --git a/packages/safe-ds-eda/types/state.d.ts b/packages/safe-ds-eda/types/state.d.ts deleted file mode 100644 index 4c42b8907..000000000 --- a/packages/safe-ds-eda/types/state.d.ts +++ /dev/null @@ -1,206 +0,0 @@ -export interface State { - tableIdentifier?: string; - table?: Table; - tabs?: Tab[]; - defaultState?: boolean; - history: HistoryEntry[]; - settings?: UserSettings; -} - -export interface HistoryEntry { - alias?: string; - action: string; - executedSdsCode: string; -} - -// ------------------ Types for the Tabs ------------------ -type TabType = 'linePlot' | 'barPlot' | 'heatmap' | 'scatterPlot' | 'infoPanel'; - -export interface TabObject { - type: TabType; - tabComment: string; - content: Object; -} - -export interface DefaultPlotTab extends TabObject { - content: { - xAxis: string; - yAxis: string; - outdated: boolean; - encodedImage: string; - }; -} -export interface LinePlotTab extends DefaultPlotTab { - type: 'linePlot'; -} - -export interface BarPlotTab extends DefaultPlotTab { - type: 'barPlot'; -} -export interface ScatterPlotTab extends DefaultPlotTab { - type: 'scatterPlot'; -} - -export interface HeatmapTab extends TabObject { - type: 'heatmap'; - content: { - outdated: boolean; - encodedImage: string; - }; -} - -export interface InfoPanelTab extends TabObject { - type: 'infoPanel'; - content: { - correlations: { columnName: string; correlation: number }[]; - outdated: boolean; - statistics: { statName: string; statValue: number }[]; - }; -} - -export type Tab = LinePlotTab | BarPlotTab | HeatmapTab | ScatterPlotTab | InfoPanelTab; - -// ------------------ Types for the Table ------------------ -export interface Table { - columns: [number, Column][]; - visibleRows?: number; - totalRows: number; - name: string; - appliedFilters: TableFilter[]; -} - -// ------------ Types for the Profiling ----------- -export interface Profiling { - validRatio: ProfilingDetailStatistical; - missingRatio: ProfilingDetailStatistical; - other: ProfilingDetail[]; -} - -export interface ProfilingDetailBase { - type: 'numerical' | 'image' | 'name'; - value: string; -} - -interface ProfilingDetailText extends ProfilingDetailBase { - interpretation: 'warn' | 'error' | 'default' | 'important' | 'good'; -} - -export interface ProfilingDetailStatistical extends ProfilingDetailText { - type: 'numerical'; - name: string; - value: string; - interpretation: ProfilingDetailText['interpretation'] | 'category'; // 'category' needed for filters, to show distinct values -} - -export interface ProfilingDetailImage extends ProfilingDetailBase { - type: 'image'; - value: Base64Image; -} - -export interface ProfilingDetailName extends ProfilingDetailText { - type: 'text'; - value: string; -} - -export type ProfilingDetail = ProfilingDetailStatistical | ProfilingDetailImage | ProfilingDetailName; - -// ------------ Types for the Columns ----------- -export interface ColumnBase { - type: 'numerical' | 'categorical'; - name: string; - values: any; - hidden: boolean; - highlighted: boolean; - appliedSort: 'asc' | 'desc' | null; - profiling?: Profiling; -} - -export interface NumericalColumn extends ColumnBase { - type: 'numerical'; - appliedFilters: NumericalFilter[]; - coloredHighLow: boolean; -} - -export interface CategoricalColumn extends ColumnBase { - type: 'categorical'; - appliedFilters: CategoricalFilter[]; -} - -export type Column = NumericalColumn | CategoricalColumn; - -// ------------ Types for the Filters ----------- -export interface FilterBase { - type: string; -} - -export interface ColumnFilterBase extends FilterBase { - type: 'valueRange' | 'specificValue' | 'searchString'; - columnName: string; -} - -export interface PossibleSearchStringFilter extends ColumnFilterBase { - type: 'searchString'; -} - -export interface SearchStringFilter extends PossibleSearchStringFilter { - searchString: string; -} - -export interface PossibleValueRangeFilter extends ColumnFilterBase { - type: 'valueRange'; - min: number; - max: number; -} - -export interface ValueRangeFilter extends PossibleValueRangeFilter { - currentMin: number; - currentMax: number; -} - -export interface PossibleSpecificValueFilter extends ColumnFilterBase { - type: 'specificValue'; - values: string[]; -} - -export interface SpecificValueFilter extends ColumnFilterBase { - type: 'specificValue'; - value: string; -} - -export type NumericalFilter = ValueRangeFilter; -export type CategoricalFilter = SearchStringFilter | SpecificValueFilter; - -export type PossibleColumnFilter = PossibleValueRangeFilter | PossibleSearchStringFilter | PossibleSpecificValueFilter; - -export interface TableFilter extends FilterBase { - type: 'hideMissingValueColumns' | 'hideNonNumericalColumns' | 'hideDuplicateRows' | 'hideRowsWithOutliers'; -} - -// ------------ Types for the Settings ----------- -export interface UserSettings { - profiling: ProfilingSettings; -} - -export interface ProfilingSettingsBase { - [key: string]: boolean; -} - -export interface ProfilingSettings extends ProfilingSettingsBase { - idNess: boolean; - maximum: boolean; - minimum: boolean; - mean: boolean; - median: boolean; - mode: boolean; - stability: boolean; - standardDeviation: boolean; - sum: boolean; - variance: boolean; -} - -// ------------ Types for general objects ----------- - -export interface Base64Image { - format: string; - bytes: string; -} diff --git a/packages/safe-ds-eda/types/state.ts b/packages/safe-ds-eda/types/state.ts new file mode 100644 index 000000000..c61cc4f6b --- /dev/null +++ b/packages/safe-ds-eda/types/state.ts @@ -0,0 +1,348 @@ +export interface State { + tableIdentifier?: string; + table?: Table; + tabs: Tab[]; + defaultState?: boolean; + history: HistoryEntry[]; + settings?: UserSettings; +} + +type InternalAction = 'reorderColumns' | 'resizeColumn' | 'hideColumn' | 'highlightColumn' | 'emptyTab'; +type ExternalManipulatingAction = 'filterColumn' | 'sortColumn' | TableFilterTypes; +type ExternalVisualizingAction = TabType | 'refreshTab'; +type Action = InternalAction | ExternalManipulatingAction | ExternalVisualizingAction; + +interface HistoryEntryBase { + type: 'internal' | 'external-manipulating' | 'external-visualizing'; + alias: string; + action: Action; +} + +export interface InternalHistoryEntryBase extends HistoryEntryBase { + type: 'internal'; + action: InternalAction; +} + +interface ExternalManipulatingHistoryEntryBase extends HistoryEntryBase { + type: 'external-manipulating'; + action: ExternalManipulatingAction; +} + +interface ExternalVisualizingHistoryEntryBase extends HistoryEntryBase { + type: 'external-visualizing'; + action: ExternalVisualizingAction; + columnNumber: 'one' | 'two' | 'none'; + existingTabId?: string; +} + +export interface InternalColumnWithValueHistoryEntry extends InternalHistoryEntryBase { + action: 'reorderColumns' | 'resizeColumn'; + columnName: string; + value: number; +} + +export interface InternalColumnHistoryEntry extends InternalHistoryEntryBase { + action: 'hideColumn' | 'highlightColumn'; + columnName: string; +} + +export interface InteralEmptyTabHistoryEntry extends InternalHistoryEntryBase { + action: 'emptyTab'; +} + +export interface ExternalManipulatingColumnFilterHistoryEntry extends ExternalManipulatingHistoryEntryBase { + action: 'filterColumn'; + columnName: string; + filter: NumericalFilter | CategoricalFilter; +} + +export interface ExternalManipulatingTableFilterHistoryEntry extends ExternalManipulatingHistoryEntryBase { + action: TableFilterTypes; +} + +export interface ExternalManipulatingColumnSortHistoryEntry extends ExternalManipulatingHistoryEntryBase { + action: 'sortColumn'; + columnName: string; + sort: PossibleSorts; +} + +export interface ExternalVisualizingNoColumnHistoryEntry extends ExternalVisualizingHistoryEntryBase { + action: NoColumnTabTypes; + columnNumber: 'none'; +} + +export interface ExternalVisualizingOneColumnHistoryEntry extends ExternalVisualizingHistoryEntryBase { + action: OneColumnTabTypes; + columnName: string; + columnNumber: 'one'; +} + +export interface ExternalVisualizingTwoColumnHistoryEntry extends ExternalVisualizingHistoryEntryBase { + action: TwoColumnTabTypes; + xAxisColumnName: string; + yAxisColumnName: string; + columnNumber: 'two'; +} + +export interface ExternalVisualizingRefreshHistoryEntry extends ExternalVisualizingHistoryEntryBase { + action: 'refreshTab'; +} + +export type TabHistoryEntry = + | ExternalVisualizingNoColumnHistoryEntry + | ExternalVisualizingOneColumnHistoryEntry + | ExternalVisualizingTwoColumnHistoryEntry; +export type InternalHistoryEntry = + | InternalColumnWithValueHistoryEntry + | InternalColumnHistoryEntry + | InteralEmptyTabHistoryEntry; +export type ExternalManipulatingHistoryEntry = + | ExternalManipulatingColumnFilterHistoryEntry + | ExternalManipulatingTableFilterHistoryEntry + | ExternalManipulatingColumnSortHistoryEntry; +export type ExternalVisualizingHistoryEntry = + | ExternalVisualizingNoColumnHistoryEntry + | ExternalVisualizingOneColumnHistoryEntry + | ExternalVisualizingTwoColumnHistoryEntry + | ExternalVisualizingRefreshHistoryEntry; + +export type ExternalHistoryEntry = ExternalManipulatingHistoryEntry | ExternalVisualizingHistoryEntry; + +export type HistoryEntry = (InternalHistoryEntry | ExternalHistoryEntry) & { + id: number; +}; + +// ------------------ Types for the Tabs ------------------ +export type TwoColumnTabTypes = 'linePlot' | 'scatterPlot'; +export type OneColumnTabTypes = 'histogram' | 'boxPlot' | 'infoPanel'; +export type NoColumnTabTypes = 'heatmap'; +type TabType = TwoColumnTabTypes | OneColumnTabTypes | NoColumnTabTypes; + +interface TabObject { + id?: string; + type: TabType; + tabComment: string; + content: Object; + imageTab: boolean; + columnNumber: 'one' | 'two' | 'none'; + isInGeneration: boolean; +} + +interface ImageTabObject extends TabObject { + imageTab: true; + content: { + outdated: boolean; + encodedImage: Base64Image; + }; +} + +interface OneColumnTabContent { + columnName: string; + outdated: boolean; + encodedImage: Base64Image; +} + +export interface OneColumnTab extends ImageTabObject { + type: Exclude; + columnNumber: 'one'; + content: OneColumnTabContent; +} + +interface InfoPanelTabContent { + correlations: { columnName: string; correlation: number }[]; + outdated: boolean; + statistics: { statName: string; statValue: number }[]; +} + +export interface InfoPanelTab extends TabObject { + imageTab: false; + type: 'infoPanel'; + columnNumber: 'none'; + content: InfoPanelTabContent; +} + +interface TwoColumnTabContent { + xAxisColumnName: string; + yAxisColumnName: string; + outdated: boolean; + encodedImage: Base64Image; +} + +export interface TwoColumnTab extends ImageTabObject { + type: TwoColumnTabTypes; + columnNumber: 'two'; + content: TwoColumnTabContent; +} + +interface NoColumnTabContent { + outdated: boolean; + encodedImage: Base64Image; +} + +export interface NoColumnTab extends ImageTabObject { + type: NoColumnTabTypes; + columnNumber: 'none'; + content: NoColumnTabContent; +} + +export interface EmptyTab { + type: 'empty'; + id: string; + isInGeneration: true; +} + +export type Tab = OneColumnTab | InfoPanelTab | TwoColumnTab | NoColumnTab | EmptyTab; +export type RealTab = Exclude; +export type PlotTab = OneColumnTab | TwoColumnTab | NoColumnTab; + +// ------------------ Types for the Table ------------------ +export interface Table { + columns: [number, Column][]; + visibleRows?: number; + totalRows: number; + name: string; + appliedFilters: TableFilter[]; +} + +// ------------ Types for the Profiling ----------- +export interface Profiling { + validRatio: ProfilingDetailStatistical; + missingRatio: ProfilingDetailStatistical; + other: ProfilingDetail[]; +} + +type BaseInterpretation = 'warn' | 'error' | 'default' | 'important' | 'good'; + +interface ProfilingDetailBase { + type: 'numerical' | 'image' | 'text'; + value: string | Base64Image; +} + +export interface ProfilingDetailStatistical extends ProfilingDetailBase { + type: 'numerical'; + name: string; + value: string; + interpretation: BaseInterpretation | 'category'; // 'category' needed for filters, to show distinct values +} + +export interface ProfilingDetailImage extends ProfilingDetailBase { + type: 'image'; + value: Base64Image; +} + +export interface ProfilingDetailName extends ProfilingDetailBase { + type: 'text'; + value: string; + interpretation: BaseInterpretation; +} + +export type ProfilingDetail = ProfilingDetailStatistical | ProfilingDetailImage | ProfilingDetailName; + +// ------------ Types for the Columns ----------- +type PossibleSorts = 'asc' | 'desc' | null; + +interface ColumnBase { + type: 'numerical' | 'categorical'; + name: string; + values: any; + hidden: boolean; + highlighted: boolean; + appliedSort: PossibleSorts; + profiling?: Profiling; +} + +export interface NumericalColumn extends ColumnBase { + type: 'numerical'; + appliedFilters: NumericalFilter[]; + coloredHighLow: boolean; +} + +export interface CategoricalColumn extends ColumnBase { + type: 'categorical'; + appliedFilters: CategoricalFilter[]; +} + +export type Column = NumericalColumn | CategoricalColumn; + +// ------------ Types for the Filters ----------- +interface FilterBase { + type: string; +} + +interface ColumnFilterBase extends FilterBase { + type: 'valueRange' | 'specificValue' | 'searchString'; + columnName: string; +} + +export interface PossibleSearchStringFilter extends ColumnFilterBase { + type: 'searchString'; +} + +export interface SearchStringFilter extends PossibleSearchStringFilter { + searchString: string; +} + +export interface PossibleValueRangeFilter extends ColumnFilterBase { + type: 'valueRange'; + min: number; + max: number; +} + +export interface ValueRangeFilter extends PossibleValueRangeFilter { + currentMin: number; + currentMax: number; +} + +export interface PossibleSpecificValueFilter extends ColumnFilterBase { + type: 'specificValue'; + values: string[]; +} + +export interface SpecificValueFilter extends ColumnFilterBase { + type: 'specificValue'; + value: string; +} + +export type NumericalFilter = ValueRangeFilter; +export type CategoricalFilter = SearchStringFilter | SpecificValueFilter; + +export type PossibleColumnFilter = PossibleValueRangeFilter | PossibleSearchStringFilter | PossibleSpecificValueFilter; + +type TableFilterTypes = + | 'hideMissingValueColumns' + | 'hideNonNumericalColumns' + | 'hideDuplicateRows' + | 'hideRowsWithOutliers'; + +export interface TableFilter extends FilterBase { + type: TableFilterTypes; +} + +// ------------ Types for the Settings ----------- +export interface UserSettings { + profiling: ProfilingSettings; +} + +interface ProfilingSettingsBase { + [key: string]: boolean; +} + +export interface ProfilingSettings extends ProfilingSettingsBase { + idNess: boolean; + maximum: boolean; + minimum: boolean; + mean: boolean; + median: boolean; + mode: boolean; + stability: boolean; + standardDeviation: boolean; + sum: boolean; + variance: boolean; +} + +// ------------ Types for general objects ----------- + +export interface Base64Image { + format: string; + bytes: string; +} diff --git a/packages/safe-ds-vscode/media/styles.css b/packages/safe-ds-vscode/media/styles.css index 61b357140..bb20002ed 100644 --- a/packages/safe-ds-vscode/media/styles.css +++ b/packages/safe-ds-vscode/media/styles.css @@ -5,6 +5,7 @@ --warn-color: #a08b14; --bg-bright: white; --bg-dark: #f2f2f2; + --bg-most-dark: #ccc; --bg-medium: #f9f9f9; --font-dark: #292929; --font-light: #6d6d6d; @@ -31,3 +32,18 @@ ::-webkit-scrollbar-corner { background-color: var(--bg-dark); } + +button { + background: none; + border: none; + cursor: pointer; + color: var(--font-dark); +} + +button:focus { + outline-color: transparent !important; +} + +button:hover { + background: none; +} 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 a15ac4dd7..93d0d2f72 100644 --- a/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts +++ b/packages/safe-ds-vscode/src/extension/eda/apis/runnerApi.ts @@ -1,16 +1,26 @@ -import { Base64Image, Column, Profiling, ProfilingDetailStatistical, Table } from '@safe-ds/eda/types/state.js'; +import { + Base64Image, + Column, + ExternalHistoryEntry, + HistoryEntry, + Profiling, + ProfilingDetailStatistical, + Table, +} from '@safe-ds/eda/types/state.js'; import { ast, CODEGEN_PREFIX, messages, SafeDsServices } from '@safe-ds/lang'; import { LangiumDocument } from 'langium'; 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'; export class RunnerApi { services: SafeDsServices; pipelinePath: vscode.Uri; pipelineName: string; pipelineNode: ast.SdsPipeline; + tablePlaceholder: string; baseDocument: LangiumDocument | undefined; placeholderCounter = 0; @@ -19,11 +29,13 @@ export class RunnerApi { pipelinePath: vscode.Uri, pipelineName: string, pipelineNode: ast.SdsPipeline, + tablePlaceholder: string, ) { this.services = services; this.pipelinePath = pipelinePath; this.pipelineName = pipelineName; this.pipelineNode = pipelineNode; + this.tablePlaceholder = tablePlaceholder; getPipelineDocument(this.pipelinePath).then((doc) => { // Get here to avoid issues because of chanigng file // Make sure to create new instance of RunnerApi if pipeline execution of fresh pipeline is needed @@ -32,7 +44,12 @@ export class RunnerApi { }); } - private async addToAndExecutePipeline(pipelineExecutionId: string, addedLines: string): Promise { + //#region Pipeline execution + private async addToAndExecutePipeline( + pipelineExecutionId: string, + addedLines: string, + placeholderNames?: string[], + ): Promise { return new Promise(async (resolve, reject) => { if (!this.baseDocument) { reject('Document not found'); @@ -47,18 +64,27 @@ export class RunnerApi { return; } + let newDocumentText; + + this.services.shared.workspace.LangiumDocuments.deleteDocument(this.pipelinePath); + const beforePipelineEnd = documentText.substring(0, endOfPipeline - 1); const afterPipelineEnd = documentText.substring(endOfPipeline - 1); - const newDocumentText = beforePipelineEnd + addedLines + afterPipelineEnd; + newDocumentText = beforePipelineEnd + addedLines + afterPipelineEnd; - this.services.shared.workspace.LangiumDocuments.deleteDocument(this.pipelinePath); - const newDoc = this.services.shared.workspace.LangiumDocuments.createDocument( - this.pipelinePath, + const newDoc = this.services.shared.workspace.LangiumDocumentFactory.fromString( newDocumentText, + this.pipelinePath, ); await this.services.shared.workspace.DocumentBuilder.build([newDoc]); - await this.services.runtime.Runner.executePipeline(pipelineExecutionId, newDoc, this.pipelineName); + safeDsLogger.debug(`Executing pipeline ${this.pipelineName} with added lines`); + await this.services.runtime.Runner.executePipeline( + pipelineExecutionId, + newDoc, + this.pipelineName, + placeholderNames, + ); this.services.shared.workspace.LangiumDocuments.deleteDocument(this.pipelinePath); this.services.shared.workspace.LangiumDocuments.addDocument(this.baseDocument); @@ -68,6 +94,7 @@ export class RunnerApi { return; } if (message.data === 'done') { + safeDsLogger.debug(`Pipeline execution ${this.pipelineName} done`); this.services.runtime.PythonServer.removeMessageCallback('runtime_progress', runtimeCallback); this.services.runtime.PythonServer.removeMessageCallback('runtime_error', errorCallback); resolve(); @@ -77,6 +104,7 @@ export class RunnerApi { if (message.id !== pipelineExecutionId) { return; } + safeDsLogger.error(`Pipeline execution ${this.pipelineName} ran into error: ${message.data}`); this.services.runtime.PythonServer.removeMessageCallback('runtime_progress', runtimeCallback); this.services.runtime.PythonServer.removeMessageCallback('runtime_error', errorCallback); reject(message.data); @@ -89,8 +117,62 @@ export class RunnerApi { }, 3000000); }); } - - // --- SDS code generation --- + //#endregion + + //#region SDS code generation + private sdsStringForHistoryEntry(historyEntry: ExternalHistoryEntry): { + sdsString: string; + placeholderNames: string[]; + } { + const newPlaceholderName = this.genPlaceholderName(); + switch (historyEntry.action) { + case 'histogram': + return { + sdsString: this.sdsStringForHistogramByColumnName( + historyEntry.columnName, + this.tablePlaceholder, + newPlaceholderName, + ), + placeholderNames: [newPlaceholderName], + }; + case 'boxPlot': + return { + sdsString: this.sdsStringForBoxplotByColumnName( + historyEntry.columnName, + this.tablePlaceholder, + newPlaceholderName, + ), + placeholderNames: [newPlaceholderName], + }; + case 'linePlot': + return { + sdsString: this.sdsStringForLinePlotByColumnNames( + historyEntry.xAxisColumnName, + historyEntry.yAxisColumnName, + this.tablePlaceholder, + newPlaceholderName, + ), + placeholderNames: [newPlaceholderName], + }; + case 'scatterPlot': + return { + sdsString: this.sdsStringForScatterPlotByColumnNames( + historyEntry.xAxisColumnName, + historyEntry.yAxisColumnName, + this.tablePlaceholder, + newPlaceholderName, + ), + placeholderNames: [newPlaceholderName], + }; + case 'heatmap': + return { + sdsString: this.sdsStringForCorrelationHeatmap(this.tablePlaceholder, newPlaceholderName), + placeholderNames: [newPlaceholderName], + }; + default: + throw new Error('Unknown history entry action: ' + historyEntry.action); + } + } private sdsStringForMissingValueRatioByColumnName( columnName: string, @@ -128,10 +210,66 @@ export class RunnerApi { ); } - // --- Placeholder handling --- + private sdsStringForBoxplotByColumnName(columnName: string, tablePlaceholder: string, newPlaceholderName: string) { + return ( + 'val ' + + newPlaceholderName + + ' = ' + + tablePlaceholder + + '.getColumn("' + + columnName + + '").plotBoxplot(); \n' + ); + } - private genPlaceholderName(): string { - return CODEGEN_PREFIX + this.placeholderCounter++; + private sdsStringForLinePlotByColumnNames( + xAxisColumnName: string, + yAxisColumnName: string, + tablePlaceholder: string, + newPlaceholderName: string, + ) { + return ( + 'val ' + + newPlaceholderName + + ' = ' + + tablePlaceholder + + '.plotLineplot(xColumnName="' + + xAxisColumnName + + '", yColumnName="' + + yAxisColumnName + + '"); \n' + ); + } + + private sdsStringForScatterPlotByColumnNames( + xAxisColumnName: string, + yAxisColumnName: string, + tablePlaceholder: string, + newPlaceholderName: string, + ) { + return ( + 'val ' + + newPlaceholderName + + ' = ' + + tablePlaceholder + + '.plotScatterplot(xColumnName="' + + xAxisColumnName + + '", yColumnName="' + + yAxisColumnName + + '"); \n' + ); + } + + private sdsStringForCorrelationHeatmap(tablePlaceholder: string, newPlaceholderName: string) { + return 'val ' + newPlaceholderName + ' = ' + tablePlaceholder + '.plotCorrelationHeatmap(); \n'; + } + //#endregion + + //#region Placeholder handling + private genPlaceholderName(suffix?: string): string { + // Filter out non-alphanumeric characters (allowing underscores), considering Unicode characters + const cleanedSuffix = suffix ? suffix.replace(/[^a-zA-Z0-9_]/gu, '') : undefined; + return CODEGEN_PREFIX + this.placeholderCounter++ + (cleanedSuffix ? '_' + cleanedSuffix : ''); } private async getPlaceholderValue(placeholder: string, pipelineExecutionId: string): Promise { @@ -149,7 +287,8 @@ export class RunnerApi { }; this.services.runtime.PythonServer.addMessageCallback('placeholder_value', placeholderValueCallback); - safeDsLogger.info('Getting placeholder from Runner ...'); + + safeDsLogger.debug('Requesting placeholder: ' + placeholder); this.services.runtime.PythonServer.sendMessageToPythonServer( messages.createPlaceholderQueryMessage(pipelineExecutionId, placeholder), ); @@ -160,9 +299,11 @@ export class RunnerApi { }); } - // --- Public API --- + //#region Public API + //#region Table fetching public async getTableByPlaceholder(tableName: string, pipelineExecutionId: string): Promise { + safeDsLogger.debug('Getting table by placeholder: ' + tableName); const pythonTableColumns = await this.getPlaceholderValue(tableName, pipelineExecutionId); if (pythonTableColumns) { const table: Table = { @@ -205,24 +346,30 @@ export class RunnerApi { return undefined; } } + //#endregion + //#region Profiling public async getProfiling(table: Table): Promise<{ columnName: string; profiling: Profiling }[]> { + safeDsLogger.debug('Getting profiling for table: ' + table.name); + const columns = table.columns; let sdsStrings = ''; + let placeholderNames: string[] = []; + const columnNameToPlaceholderMVNameMap = new Map(); // Mapping random placeholder name for missing value ratio back to column name const missingValueRatioMap = new Map(); // Saved by random placeholder name - const columnNameToPlaceholderIDnessNameMap = new Map(); // Mapping random placeholder name for IDness back to column name - const idnessMap = new Map(); // Saved by random placeholder name - const columnNameToPlaceholderHistogramNameMap = new Map(); // Mapping random placeholder name for histogram back to column name const histogramMap = new Map(); // Saved by random placeholder name + const uniqueValuesMap = new Map>(); + // Generate SDS code to get missing value ratio for each column - outer: for (const column of columns) { - const newMvPlaceholderName = this.genPlaceholderName(); + for (const column of columns) { + const newMvPlaceholderName = this.genPlaceholderName(column[1].name + '_mv'); + placeholderNames.push(newMvPlaceholderName); columnNameToPlaceholderMVNameMap.set(column[1].name, newMvPlaceholderName); sdsStrings += this.sdsStringForMissingValueRatioByColumnName( column[1].name, @@ -230,29 +377,31 @@ export class RunnerApi { newMvPlaceholderName, ); - // Only need to check IDness for non-numerical columns + // Find unique values + // TODO reevaluate when image stuck problem fixed + let uniqueValues = new Set(); + for (let j = 0; j < column[1].values.length; j++) { + uniqueValues.add(column[1].values[j]); + } + uniqueValuesMap.set(column[1].name, uniqueValues); + + // Different histogram conditions for numerical and categorical columns if (column[1].type !== 'numerical') { - const newIDnessPlaceholderName = this.genPlaceholderName(); - columnNameToPlaceholderIDnessNameMap.set(column[1].name, newIDnessPlaceholderName); - sdsStrings += this.sdsStringForIDnessByColumnName(column[1].name, table.name, newIDnessPlaceholderName); - - // Find unique values - // TODO reevaluate when image stuck problem fixed - let uniqueValues = new Set(); - for (let j = 0; j < column[1].values.length; j++) { - uniqueValues.add(column[1].values[j]); - if (uniqueValues.size > 10) { - continue outer; - } - } if (uniqueValues.size <= 3 || uniqueValues.size > 10) { - // Must match conidtions below that choose to display histogram + // Must match conidtions below that choose to display histogram for categorical columns continue; // This historam only generated if between 4-10 categorigal uniques or numerical type } + } else { + if (uniqueValues.size > column[1].values.length * 0.9) { + // Must match conidtions below that choose to display histogram for numerical columns + // If 90% of values are unique, it's not a good idea to display histogram + continue; + } } // Histogram for numerical columns or categorical columns with 4-10 unique values - const newHistogramPlaceholderName = this.genPlaceholderName(); + const newHistogramPlaceholderName = this.genPlaceholderName(column[1].name + '_hist'); + placeholderNames.push(newHistogramPlaceholderName); columnNameToPlaceholderHistogramNameMap.set(column[1].name, newHistogramPlaceholderName); sdsStrings += this.sdsStringForHistogramByColumnName( column[1].name, @@ -264,9 +413,8 @@ export class RunnerApi { // Execute with generated SDS code const pipelineExecutionId = crypto.randomUUID(); try { - await this.addToAndExecutePipeline(pipelineExecutionId, sdsStrings); + await this.addToAndExecutePipeline(pipelineExecutionId, sdsStrings, placeholderNames); } catch (e) { - safeDsLogger.info('Error during pipeline execution: ' + e); throw e; } @@ -278,14 +426,6 @@ export class RunnerApi { } } - // Get IDness for each column - for (const [, placeholderName] of columnNameToPlaceholderIDnessNameMap) { - const idness = await this.getPlaceholderValue(placeholderName, pipelineExecutionId); - if (idness) { - idnessMap.set(placeholderName, idness as number); - } - } - // Get histogram for each column for (const [, placeholderName] of columnNameToPlaceholderHistogramNameMap) { const histogram = await this.getPlaceholderValue(placeholderName, pipelineExecutionId); @@ -315,17 +455,15 @@ export class RunnerApi { interpretation: missingValuesRatio > 0 ? 'error' : 'default', }; + const uniqueValues = uniqueValuesMap.get(column[1].name)!.size; // If not numerical, add proper profilings according to idness results if (column[1].type !== 'numerical') { - const idness = idnessMap.get(columnNameToPlaceholderIDnessNameMap.get(column[1].name)!)!; - const uniqueValues = idness * column[1].values.length; - if (uniqueValues <= 3) { // Can display each separate percentages of unique values // Find all unique values and count them const uniqueValueCounts = new Map(); for (let i = 0; i < column[1].values.length; i++) { - if (column[1].values[i]) + if (column[1].values[i] !== undefined && column[1].values[i] !== null) uniqueValueCounts.set( column[1].values[i], (uniqueValueCounts.get(column[1].values[i]) || 0) + 1, @@ -399,23 +537,155 @@ export class RunnerApi { }); } } else { - // Display histogram for numerical columns - const histogram = histogramMap.get(columnNameToPlaceholderHistogramNameMap.get(column[1].name)!)!; - - profiling.push({ - columnName: column[1].name, - profiling: { - validRatio, - missingRatio, - other: [ - { type: 'text', value: 'Numerical', interpretation: 'important' }, - { type: 'image', value: histogram }, - ], - }, - }); + if (uniqueValues > column[1].values.length * 0.9) { + profiling.push({ + columnName: column[1].name, + profiling: { + validRatio, + missingRatio, + other: [ + { type: 'text', value: 'Categorical', interpretation: 'important' }, + { + type: 'text', + value: uniqueValues + ' Distincts', + interpretation: 'default', + }, + { + type: 'text', + value: + Math.round( + column[1].values.length * + (1 - + (missingValueRatioMap.get( + columnNameToPlaceholderMVNameMap.get(column[1].name)!, + ) || 0)), + ) + ' Total Valids', + interpretation: 'default', + }, + ], + }, + }); + } else { + const histogram = histogramMap.get(columnNameToPlaceholderHistogramNameMap.get(column[1].name)!)!; + + profiling.push({ + columnName: column[1].name, + profiling: { + validRatio, + missingRatio, + other: [ + { type: 'text', value: 'Numerical', interpretation: 'important' }, + { type: 'image', value: histogram }, + ], + }, + }); + } } } return profiling; } + //#endregion + + //#region History + public async executeHistoryAndReturnNewResult( + pastEntries: HistoryEntry[], + newEntry: HistoryEntry, + ): Promise { + let sdsLines = ''; + let placeholderNames: string[] = []; + for (const entry of pastEntries) { + 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 sdsString = this.sdsStringForHistoryEntry(entry).sdsString; + if (sdsString) { + sdsLines += sdsString + '\n'; + } + safeDsLogger.debug(`Running old entry ${entry.id} with action ${entry.action}`); + } + } + + if (newEntry.type === 'external-visualizing') { + if (newEntry.action === 'infoPanel' || newEntry.action === 'refreshTab') throw new Error('Not implemented'); + + const sdsStringObj = this.sdsStringForHistoryEntry(newEntry); + sdsLines += sdsStringObj.sdsString + '\n'; + placeholderNames = sdsStringObj.placeholderNames; + + safeDsLogger.debug(`Running new entry ${newEntry.id} with action ${newEntry.action}`); + } else if (newEntry.type === 'external-manipulating') { + throw new Error('Not implemented'); + } else if (newEntry.type === 'internal') { + throw new Error('Cannot execute internal history entry in Runner'); + } + + const pipelineExecutionId = crypto.randomUUID(); + try { + await this.addToAndExecutePipeline(pipelineExecutionId, sdsLines, placeholderNames); + } catch (e) { + throw e; + } + + if ( + newEntry.type === 'external-visualizing' && + newEntry.action !== 'infoPanel' && + placeholderNames.length > 0 + ) { + const result = await this.getPlaceholderValue(placeholderNames[0]!, pipelineExecutionId); + const image = result as Base64Image; + + if (newEntry.columnNumber === 'none') { + return { + type: 'tab', + historyId: newEntry.id, + content: { + tabComment: '', + type: newEntry.action, + columnNumber: newEntry.columnNumber, + imageTab: true, + isInGeneration: false, + id: newEntry.existingTabId, + content: { outdated: false, encodedImage: image }, + }, + }; + } else if (newEntry.columnNumber === 'two') { + return { + type: 'tab', + historyId: newEntry.id, + content: { + tabComment: newEntry.xAxisColumnName + ' x ' + newEntry.yAxisColumnName, + type: newEntry.action, + columnNumber: newEntry.columnNumber, + imageTab: true, + isInGeneration: false, + id: newEntry.existingTabId, + content: { + outdated: false, + encodedImage: image, + xAxisColumnName: newEntry.xAxisColumnName, + yAxisColumnName: newEntry.yAxisColumnName, + }, + }, + }; + } else { + return { + type: 'tab', + historyId: newEntry.id, + content: { + tabComment: newEntry.columnName, + type: newEntry.action, + columnNumber: newEntry.columnNumber, + imageTab: true, + isInGeneration: false, + id: newEntry.existingTabId, + content: { outdated: false, encodedImage: image, columnName: newEntry.columnName }, + }, + }; + } + } else { + throw new Error('Not implemented'); + } + } + //#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 1ca5847ab..bb7ee5ecd 100644 --- a/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts +++ b/packages/safe-ds-vscode/src/extension/eda/edaPanel.ts @@ -1,8 +1,8 @@ import * as vscode from 'vscode'; import { ToExtensionMessage } from '@safe-ds/eda/types/messaging.js'; import * as webviewApi from './apis/webviewApi.ts'; -import { State } from '@safe-ds/eda/types/state.js'; -import { ast, SafeDsServices } from '@safe-ds/lang'; +import { State } from '@safe-ds/eda/types/state.ts'; +import { SafeDsServices, ast } from '@safe-ds/lang'; import { RunnerApi } from './apis/runnerApi.ts'; import { safeDsLogger } from '../helpers/logging.js'; @@ -26,6 +26,7 @@ export class EDAPanel { private startPipelineExecutionId: string; private runnerApi: RunnerApi; + //#region Creation private constructor( panel: vscode.WebviewPanel, extensionUri: vscode.Uri, @@ -39,7 +40,7 @@ export class EDAPanel { this.panel = panel; this.extensionUri = extensionUri; this.startPipelineExecutionId = startPipelineExecutionId; - this.runnerApi = new RunnerApi(EDAPanel.services, pipelinePath, pipelineName, pipelineNode); + this.runnerApi = new RunnerApi(EDAPanel.services, pipelinePath, pipelineName, pipelineNode, tableName); this.tableName = tableName; // Set the webview's initial html content @@ -56,13 +57,14 @@ export class EDAPanel { if (updatedPanel.visible) { this.column = updatedPanel.viewColumn; } + // TODO handle floating panels if at some point possible }); this.disposables.push(this.viewStateChangeListener); // Handle messages from the webview const webview = this.panel.webview; this.webviewListener = webview.onDidReceiveMessage(async (data: ToExtensionMessage) => { - safeDsLogger.info(data.command + ' called'); + safeDsLogger.debug(data.command + ' called'); switch (data.command) { case 'setInfo': { if (!data.value) { @@ -78,24 +80,56 @@ export class EDAPanel { vscode.window.showErrorMessage(data.value); break; } - case 'setCurrentGlobalState': { - // if (!data.value) { - // return; - // } - // const existingStates = (EDAPanel.context.globalState.get('webviewState') ?? []) as State[]; - // const stateExists = existingStates.some((s) => s.tableIdentifier === data.value.tableIdentifier); - - // const newWebviewState = stateExists - // ? (existingStates.map((s) => - // s.tableIdentifier === data.value.tableIdentifier ? data.value : s, - // ) as State[]) - // : existingStates.concat(data.value); - - // EDAPanel.context.globalState.update('webviewState', newWebviewState); - break; - } - case 'resetGlobalState': { - EDAPanel.context.globalState.update('webviewState', []); + case 'executeRunner': { + if (!data.value) { + return; + } + + let alreadyComplete = false; + + // Execute the runner + const resultPromise = this.runnerApi.executeHistoryAndReturnNewResult( + data.value.pastEntries, + data.value.newEntry, + ); + + // Check if execution takes longer than 1s to show progress indicator + setTimeout(() => { + if (!alreadyComplete) { + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Executing action ...', + cancellable: true, + }, + async (progress, token) => { + token.onCancellationRequested(() => { + if (data.value.newEntry) { + safeDsLogger.info('User canceled execution.'); + webviewApi.postMessage(this.panel.webview, { + command: 'cancelRunnerExecution', + value: data.value.newEntry, + }); + } else { + throw new Error('No history entry to cancel'); + } + }); + + // Wait for the result to finish in case it's still running + await resultPromise; + alreadyComplete = true; // Mark completion to prevent multiple indicators + }, + ); + } + }, 1000); + + const result = await resultPromise; + alreadyComplete = true; + + webviewApi.postMessage(this.panel.webview, { + command: 'runnerExecutionResult', + value: result, + }); break; } } @@ -127,7 +161,7 @@ export class EDAPanel { panel.panel.reveal(panel.column); panel.tableIdentifier = tableIdentifier; panel.startPipelineExecutionId = startPipelineExecutionId; - panel.runnerApi = new RunnerApi(services, pipelinePath, pipelineName, pipelineNode); + panel.runnerApi = new RunnerApi(services, pipelinePath, pipelineName, pipelineNode, tableName); panel.tableName = tableName; EDAPanel.panelsMap.set(tableIdentifier, panel); @@ -191,7 +225,9 @@ export class EDAPanel { } } } + //#endregion + //#region Disposal public static kill(tableIdentifier: string) { safeDsLogger.info('kill ' + tableIdentifier); let panel = EDAPanel.panelsMap.get(tableIdentifier); @@ -202,6 +238,7 @@ export class EDAPanel { } public dispose() { + safeDsLogger.info('dispose ' + this.tableIdentifier); EDAPanel.panelsMap.delete(this.tableIdentifier); // Clean up our panel @@ -215,7 +252,34 @@ export class EDAPanel { } } } + //#endregion + //#region State handling + private async constructCurrentState(): Promise<{ state: State; fromExisting: boolean }> { + const panel = EDAPanel.panelsMap.get(this.tableIdentifier); + if (!panel) { + throw new Error('Panel not found.'); + } else { + const table = await panel.runnerApi.getTableByPlaceholder(this.tableName, this.startPipelineExecutionId); + if (!table) { + throw new Error('Timeout waiting for placeholder value'); + } else { + return { + state: { + tableIdentifier: panel.tableIdentifier, + history: [], + defaultState: false, + table, + tabs: [], + }, + fromExisting: false, + }; + } + } + } + //#endregion + + //#region Html updating private async _update() { const webview = this.panel.webview; this.panel.webview.html = await this._getHtmlForWebview(webview); @@ -239,39 +303,6 @@ export class EDAPanel { }); }; - // private findCurrentState(): State | undefined { - // const existingStates = (EDAPanel.context.globalState.get('webviewState') ?? []) as State[]; - // return existingStates.find((s) => s.tableIdentifier === this.tableIdentifier); - // } - - private async constructCurrentState(): Promise<{ state: State; fromExisting: boolean }> { - // const existingCurrentState = this.findCurrentState(); - // if (existingCurrentState) { - // printOutputMessage('Found current State.'); - // return { state: existingCurrentState, fromExisting: true }; - // } - // - const panel = EDAPanel.panelsMap.get(this.tableIdentifier); - if (!panel) { - throw new Error('RunnerApi panel not found.'); - } else { - const table = await panel.runnerApi.getTableByPlaceholder(this.tableName, this.startPipelineExecutionId); - if (!table) { - throw new Error('Timeout waiting for placeholder value'); - } else { - return { - state: { - tableIdentifier: panel.tableIdentifier, - history: [], - defaultState: false, - table, - }, - fromExisting: false, - }; - } - } - } - private async _getHtmlForWebview(webview: vscode.Webview) { // The uri we use to load this script in the webview let scriptUri; @@ -327,4 +358,5 @@ export class EDAPanel { } return text; } + //#endregion }