Skip to content

Commit

Permalink
feat: eda plot view (#1161)
Browse files Browse the repository at this point in the history
Closes #955
Closes #986

### Summary of Changes

Implemented history/action structure for webview to add new actions to
state and have them (if external and info not already present) send
execute requests to runner that are then cancellable or deployed in
correct order. Only fully working for Plots/Tabs at the moment, that are
on deploy added to tabs state and set as currentIndex. All Tabs and
Table retain their state. Runner uses existing methods in RunnerAPI to
get back to relevant state by executing past manipulating actions and
then returns the result of the new action (only if plots right now).

Tabs can be created by selecting columns and right clicking, zooming in
on profiling images or by creating an empty tab with the plus icon in
the sidebar. There in the guided menu users can change the current Tab
to display other info. At that point the tab will go into building state
and show prompts, loading screens and buttons accordingly. Typings are
adapted to abstract as much as possible (mainly around column count for
tabs, none, one and two) and stores are heavily used for reactivity.

---------

Co-authored-by: Lars Reimann <mail@larsreimann.com>
Co-authored-by: WinPlay02 <winplay02_gh@woberlaender.de>
Co-authored-by: megalinter-bot <129584137+megalinter-bot@users.noreply.github.com>
  • Loading branch information
4 people authored May 11, 2024
1 parent cfdbc96 commit a216743
Show file tree
Hide file tree
Showing 34 changed files with 2,366 additions and 621 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions packages/safe-ds-eda/consts.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const imageWidthToHeightRatio = 1 + 1 / 3;
33 changes: 25 additions & 8 deletions packages/safe-ds-eda/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,12 +39,21 @@
</script>

<main>
<div class="sidebarWrapper" style:width="{sidebarWidth}px" class:white-bg={sidebarWidth < 100}>
<div class="sidebarWrapper" style:width="{sidebarWidth}px">
<Sidebar width={sidebarWidth} />
<button class="resizer" on:mousedown={handleDrag}></button>
</div>
<div class="tableWrapper">
<TableView {sidebarWidth} />
<div class="contentWrapper">
<div class:hide={$currentTabIndex !== undefined}>
<TableView {sidebarWidth} />
</div>
{#if $currentState.tabs}
{#each $currentState.tabs as tab, index}
<div class:hide={index !== $currentTabIndex}>
<TabContent {tab} {sidebarWidth} />
</div>
{/each}
{/if}
</div>
</main>

Expand All @@ -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 {
Expand All @@ -79,4 +92,8 @@
cursor: ew-resize;
background-color: transparent;
}
.hide {
display: none;
}
</style>
20 changes: 5 additions & 15 deletions packages/safe-ds-eda/src/apis/extensionApi.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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 } });
};
245 changes: 245 additions & 0 deletions packages/safe-ds-eda/src/apis/historyApi.ts
Original file line number Diff line number Diff line change
@@ -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
};
25 changes: 25 additions & 0 deletions packages/safe-ds-eda/src/components/NewTabButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { addEmptyTabHistoryEntry } from '../apis/historyApi';
import PlusIcon from '../icons/Plus.svelte';
import { preventClicks } from '../webviewState';
const createEmptyTab = function () {
if ($preventClicks) return;
addEmptyTabHistoryEntry();
};
</script>

<div class="wrapper">
<div role="none" class="iconWrapper" on:click={createEmptyTab}>
<PlusIcon />
</div>
</div>

<style>
.wrapper {
width: 35px;
height: 35px;
cursor: pointer;
position: relative;
}
</style>
Loading

0 comments on commit a216743

Please sign in to comment.