Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion org.knime.r/META-INF/MANIFEST.MF
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ Require-Bundle: org.eclipse.ui;bundle-version="[3.6.1,4.0.0)",
org.knime.core.pmml;bundle-version="[5.9.0,6.0.0)",
org.knime.filehandling.core;bundle-version="[5.9.0,6.0.0)",
org.knime.workbench.core;bundle-version="[5.9.0,6.0.0)",
org.knime.core.ui;bundle-version="[5.11.0,6.0.0)"
org.knime.core.ui;bundle-version="[5.11.0,6.0.0)",
com.fasterxml.jackson.core.jackson-core;bundle-version="[2.13.2,3.0.0)",
com.fasterxml.jackson.core.jackson-databind;bundle-version="[2.13.2.2,3.0.0)",
com.fasterxml.jackson.core.jackson-annotations;bundle-version="[2.16.1,3.0.0)"
Bundle-RequiredExecutionEnvironment: JavaSE-17
Bundle-ActivationPolicy: lazy
Eclipse-RegisterBuddy: org.knime.base
Expand Down
102 changes: 100 additions & 2 deletions org.knime.r/js-src/src/components/App.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, watchEffect } from "vue";
import { ref, watch, watchEffect } from "vue";
import * as monaco from "monaco-editor";

import { FunctionButton } from "@knime/components";
import {
CompactTabBar,
type ConsoleHandler,
type GenericNodeSettings,
InputOutputPane,
Expand All @@ -12,6 +13,7 @@ import {
consoleHandler,
editor,
getInitialData,
getScriptingService,
initConsoleEventHandler,
joinSettings,
setConsoleHandler,
Expand All @@ -20,9 +22,12 @@ import { NodeParametersPanel } from "@knime/scripting-editor/parameters";
import TrashIcon from "@knime/styles/img/icons/trash.svg";

import REditorControls from "@/components/REditorControls.vue";
import { useSessionStatusStore } from "@/store";

const initialData = getInitialData();

const sessionStore = useSessionStatusStore();

const inputOutputItems = [
...initialData.inputObjects,
initialData.flowVariables,
Expand All @@ -45,6 +50,56 @@ watchEffect(() => {
monaco.editor.EndOfLineSequence.LF,
);
});

// Right pane tabs
type RightPaneTab = "variables" | "settings" | "plot";
const rightPaneActiveTab = ref<RightPaneTab>("settings");
const rightPaneOptions = [
{ value: "variables", label: "Variables" },
{ value: "settings", label: "Settings" },
{ value: "plot", label: "Plot" },
];

// Connect to the R language server once the Monaco editor model is available.
// watch() is used (not initR()) because the editor model only exists after the
// Vue component tree is mounted — calling connectToLanguageServer() before that
// would throw "Editor model has not yet been initialized".
watch(
() => mainEditorState.value?.editorModel,
(editorModel) => {
if (typeof editorModel === "undefined") {
return;
}
getScriptingService()
.sendToService("getRInfo")
.then((info: string) => {
consoleHandler.writeln({ text: `Using ${info}\n` });
})
.catch(() => {
/* ignore */
});
consoleHandler.writeln({ text: "Connecting to R language server…\n" });
getScriptingService()
.connectToLanguageServer()
.then(() => {
consoleHandler.writeln({
text:
"R language server connected. Hover and autocompletion are active.\n" +
"Note: autocompletion requires typing a partial identifier (e.g. 'pri') " +
"before pressing Ctrl+Space.\n",
});
})
.catch((e: Error) => {
consoleHandler.writeln({
warning:
`R language server unavailable: ${e.message}\n` +
"Install the 'languageserver' package to enable live autocompletion:\n" +
" install.packages('languageserver')\n",
});
});
},
{ once: true },
);
</script>

<template>
Expand All @@ -70,7 +125,34 @@ watchEffect(() => {
<InputOutputPane :input-output-items="inputOutputItems" />
</template>
<template #right-pane>
<NodeParametersPanel ref="nodeParametersPanel" />
<div id="right-pane">
<CompactTabBar
v-model="rightPaneActiveTab"
:possible-values="rightPaneOptions"
name="rightPaneTabBar"
/>
<div id="right-pane-content">
<div v-show="rightPaneActiveTab === 'variables'" class="tab-placeholder">
<!-- Variables: reserved for future use -->
</div>
<NodeParametersPanel
v-show="rightPaneActiveTab === 'settings'"
ref="nodeParametersPanel"
/>
<div v-show="rightPaneActiveTab === 'plot'" class="plot-pane">
<img
v-if="sessionStore.latestPlotData"
:src="'data:image/png;base64,' + sessionStore.latestPlotData"
class="plot-image"
alt="R plot"
/>
<div v-else class="plot-empty">
No plot yet. Run a script that calls <code>plot()</code>,
<code>ggplot()</code>, or any other graphics function.
</div>
</div>
</div>
</div>
</template>
<template #code-editor-controls>
<REditorControls />
Expand Down Expand Up @@ -102,6 +184,22 @@ watchEffect(() => {
</style>

<style scoped>
#right-pane {
display: flex;
flex-direction: column;
height: 100%;
}

#right-pane-content {
flex: 1;
min-height: 0;
overflow: auto;
}

.tab-placeholder {
height: 100%;
}

.plot-pane {
box-sizing: border-box;
display: flex;
Expand Down
107 changes: 106 additions & 1 deletion org.knime.r/js-src/src/components/REditorControls.vue
Original file line number Diff line number Diff line change
@@ -1 +1,106 @@
<template>TODO Run R script</template>
<script setup lang="ts">
import { computed } from "vue";

import { Button, LoadingIcon } from "@knime/components";
import { editor } from "@knime/scripting-editor";
import CancelIcon from "@knime/styles/img/icons/circle-close.svg";
import PlayIcon from "@knime/styles/img/icons/play.svg";
import ReloadIcon from "@knime/styles/img/icons/reload.svg";

import { rScriptingService } from "@/r-scripting-service";
import { useSessionStatusStore } from "@/store";

const sessionStatus = useSessionStatusStore();
const mainEditorState = editor.useMainCodeEditorStore();

const running = computed(
() =>
sessionStatus.status === "RUNNING_ALL" ||
sessionStatus.status === "RUNNING_SELECTED",
);
const runningAll = computed(() => sessionStatus.status === "RUNNING_ALL");
const runningSelected = computed(
() => sessionStatus.status === "RUNNING_SELECTED",
);
const hasSelection = computed(
() => (mainEditorState.value?.selection.value ?? "") !== "",
);

const runAllClicked = () => {
if (runningAll.value) {
rScriptingService.killSession();
} else {
rScriptingService.runScript();
}
};

const runSelectedClicked = () => {
if (runningSelected.value) {
rScriptingService.killSession();
} else {
rScriptingService.runSelectedLines();
}
};
</script>

<template>
<div class="r-editor-controls">
<Button
compact
with-border
:disabled="(running && !runningSelected) || !hasSelection"
title="Run selected lines"
@click="runSelectedClicked"
>
<PlayIcon v-if="!runningSelected" />
<LoadingIcon v-else class="spinning" />
<CancelIcon v-if="runningSelected" />
{{ runningSelected ? "Running..." : "Run selected lines" }}
</Button>
<Button
primary
compact
:disabled="running && !runningAll"
title="Run all – Shift+Enter"
@click="runAllClicked"
>
<PlayIcon v-if="!runningAll" />
<LoadingIcon v-else class="spinning" />
<CancelIcon v-if="runningAll" />
{{ runningAll ? "Running..." : "Run all" }}
</Button>
<Button
compact
with-border
:disabled="running"
title="Reset workspace"
@click="rScriptingService.resetWorkspace()"
>
<ReloadIcon />
Reset
</Button>
</div>
</template>

<style scoped lang="postcss">
.r-editor-controls {
display: flex;
gap: 8px;
align-items: center;
}

.spinning {
animation: spin 1s linear infinite;
}

@keyframes spin {
from {
transform: rotate(0deg);
}

to {
transform: rotate(360deg);
}
}
</style>

6 changes: 3 additions & 3 deletions org.knime.r/js-src/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { init } from "@knime/scripting-editor";

import App from "@/components/App.vue";

import { initREventHandlers } from "./r-scripting-service";

// Setup global consola instance
window.consola = createConsola({
level: import.meta.env.DEV ? LogLevels.trace : LogLevels.error,
});

// NOTE: For development, the legacy mode can be disabled and dark mode can be forced here
// const { currentMode } = useKdsDarkMode();
// currentMode.value = "dark"
useKdsLegacyMode(true);

await init();
initREventHandlers();

createApp(App).mount("#app");
56 changes: 56 additions & 0 deletions org.knime.r/js-src/src/r-scripting-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { editor, getScriptingService } from "@knime/scripting-editor";

import { type ExecutionStatus, useSessionStatusStore } from "./store";

type ExecutionFinishedInfo = {
status: ExecutionStatus;
message?: string;
};

const sessionStatus = useSessionStatusStore();
const mainEditorState = editor.useMainCodeEditorStore();

export const rScriptingService = {
/** Runs the full script in a new R session. The old session (if any) is closed first. */
runScript: (): void => {
const script = mainEditorState.value?.text.value ?? "";
getScriptingService().sendToService("runScript", [script]);
sessionStatus.status = "RUNNING_ALL";
},

/** Runs the selected lines (or current line) in the existing R session. */
runSelectedLines: (): void => {
const selectedLines = mainEditorState.value?.selectedLines.value ?? "";
getScriptingService().sendToService("runInExistingSession", [selectedLines]);
sessionStatus.status = "RUNNING_SELECTED";
},

/** Kills the running R session. */
killSession: (): void => {
getScriptingService().sendToService("killSession");
},

/** Resets the R workspace by clearing all variables and re-importing input data. */
resetWorkspace: (): void => {
getScriptingService().sendToService("resetWorkspace");
sessionStatus.status = "RUNNING_ALL";
},
};

/** Registers all event handlers for R scripting events. Must be called once on startup. */
export const initREventHandlers = (): void => {
getScriptingService().registerEventHandler(
"r-execution-finished",
(info: ExecutionFinishedInfo) => {
sessionStatus.status = "IDLE";
sessionStatus.lastExecutionStatus = info.status;
},
);

getScriptingService().registerEventHandler(
"r-plot",
(base64Png: string) => {
sessionStatus.latestPlotData = base64Png;
},
);
};
19 changes: 19 additions & 0 deletions org.knime.r/js-src/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { reactive } from "vue";

export type SessionStatus = "IDLE" | "RUNNING_ALL" | "RUNNING_SELECTED";

export type ExecutionStatus = "SUCCESS" | "ERROR" | "CANCELLED";

export type SessionStatusStore = {
status: SessionStatus;
lastExecutionStatus?: ExecutionStatus;
/** Base64-encoded PNG of the most recent plot, or null if no plot has been generated yet. */
latestPlotData: string | null;
};

const sessionStatus: SessionStatusStore = reactive<SessionStatusStore>({
status: "IDLE",
latestPlotData: null,
});

export const useSessionStatusStore = (): SessionStatusStore => sessionStatus;
4 changes: 2 additions & 2 deletions org.knime.r/js-src/src/test-setup/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ vi.mock("@knime/ui-extension-service", () => ({
// Initialize @knime/scripting-editor with mock data
initMocked({
scriptingService: {
sendToService: vi.fn(),
sendToService: vi.fn(() => Promise.resolve(undefined)),
callRpcMethod: vi.fn(),
getOutputPreviewTableInitialData: vi.fn(() => Promise.resolve(undefined)),
registerEventHandler: vi.fn(),
connectToLanguageServer: vi.fn(),
connectToLanguageServer: vi.fn(() => Promise.resolve()),
isKaiEnabled: vi.fn(),
isLoggedIntoHub: vi.fn(),
getAiDisclaimer: vi.fn(),
Expand Down
Loading
Loading