diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts
new file mode 100644
index 00000000000..dc13297dd46
--- /dev/null
+++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts
@@ -0,0 +1,145 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page';
+import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page';
+import {
+ addPageAnnotationBeforeEach,
+ performLoginIfRequired,
+ waitForZeppelinReady,
+ PAGES,
+ createTestNotebook
+} from '../../../utils';
+
+test.describe('Copy table result to clipboard', () => {
+ addPageAnnotationBeforeEach(PAGES.SHARE.SHARE_RESULT);
+
+ let paragraphPage: NotebookParagraphPage;
+ let testNotebook: { noteId: string; paragraphId: string };
+
+ test.beforeEach(async ({ page, context }, testInfo) => {
+ testInfo.skip(!!process.env.CI, 'Requires a running shell interpreter — skipped on CI');
+ // Grant clipboard permissions so navigator.clipboard.writeText works in tests
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+
+ await page.goto('/#/');
+ await waitForZeppelinReady(page);
+ await performLoginIfRequired(page);
+
+ testNotebook = await createTestNotebook(page);
+ paragraphPage = new NotebookParagraphPage(page);
+
+ await page.goto(`/#/notebook/${testNotebook.noteId}`);
+ await page.waitForLoadState('networkidle');
+
+ // Type a paragraph that outputs a TABLE result using the %sh interpreter
+ await paragraphPage.doubleClickToEdit();
+ await expect(paragraphPage.codeEditor).toBeVisible();
+
+ const codeEditor = paragraphPage.codeEditor.locator('textarea, .monaco-editor .input-area').first();
+ await expect(codeEditor).toBeAttached({ timeout: 10000 });
+ await codeEditor.focus();
+
+ const keyboard = new NotebookKeyboardPage(page);
+ await keyboard.pressSelectAll();
+ await page.keyboard.type('%sh\nprintf "name\\tcount\\na\\t12\\nb\\t24\\n"');
+
+ await paragraphPage.runParagraph();
+ await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 30000 });
+ });
+
+ test('export dropdown should contain Copy as TSV and Copy as CSV options', async ({ page }) => {
+ // Open the export dropdown (down-arrow button next to the download icon)
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+
+ await expect(menu.locator('li:has-text("Download as CSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Download as TSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Copy as TSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Copy as CSV")')).toBeVisible();
+ });
+
+ test('Copy as TSV should write tab-delimited data with headers to clipboard', async ({ page }) => {
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as TSV")').click();
+
+ // Read back what was written to the clipboard
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ // First line must be the header row
+ expect(lines[0]).toBe('name\tcount');
+ // Data rows follow
+ expect(lines[1]).toBe('a\t12');
+ expect(lines[2]).toBe('b\t24');
+ });
+
+ test('Copy as CSV should write comma-delimited data with headers to clipboard', async ({ page }) => {
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as CSV")').click();
+
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ expect(lines[0]).toBe('name,count');
+ expect(lines[1]).toBe('a,12');
+ expect(lines[2]).toBe('b,24');
+ });
+
+ test('Copy as CSV should quote cell values that contain double quotes', async ({ page }) => {
+ // Re-run the paragraph with a value containing a double quote
+ const codeEditor = page.locator('.monaco-editor .input-area, textarea').first();
+ await codeEditor.focus();
+ const keyboard = new NotebookKeyboardPage(page);
+ await keyboard.pressSelectAll();
+ await page.keyboard.type('%sh\nprintf "col1\\tcol2\\nsay \\"hi\\"\\t1\\n"');
+ await new NotebookParagraphPage(page).runParagraph();
+ await page.waitForLoadState('networkidle');
+
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as CSV")').click();
+
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ // 'say "hi"' contains double quotes — must be RFC 4180 quoted in CSV output
+ expect(lines[1]).toBe('"say ""hi""",1');
+ });
+});
diff --git a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
index ce70756b3ec..878bd2efdc4 100644
--- a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
+++ b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
@@ -42,8 +42,11 @@
- - CSV
- - TSV
+ - Download as CSV
+ - Download as TSV
+
+ - Copy as TSV
+ - Copy as CSV
diff --git a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
index 04b994911b6..85f9715c7f2 100644
--- a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
+++ b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
@@ -271,6 +271,41 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
}
}
+ copyToClipboard(type: 'tsv' | 'csv'): void {
+ if (!this.tableData || !this.tableData.rows) {
+ return;
+ }
+ const delimiter = type === 'tsv' ? '\t' : ',';
+ const { columns, rows } = this.tableData;
+ const escape = (value: unknown): string => {
+ const str = String(value ?? '');
+ return str.includes(delimiter) || str.includes('"') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str;
+ };
+ const lines = [
+ columns.map(escape).join(delimiter),
+ ...rows.map(row => columns.map(col => escape(row[col])).join(delimiter))
+ ];
+ const text = lines.join('\n');
+ // TODO: Refactor the duplicated copy-to-clipboard logics
+ const fallbackCopy = () => {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ };
+ // navigator.clipboard is undefined in non-secure contexts (e.g. plain HTTP),
+ // where writeText would throw synchronously before the catch could run.
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).catch(fallbackCopy);
+ } else {
+ fallbackCopy();
+ }
+ }
+
switchMode(mode: VisualizationMode) {
if (!this.config) {
throw new Error('config is not defined');
diff --git a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
index 7ea134b803c..f5213cc831c 100644
--- a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
+++ b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
@@ -28,6 +28,19 @@
Export visible data as excel
+
+
+ Copy all data as TSV
+
+
+ Copy all data as CSV
+
+
+ Copy visible data as TSV
+
+
+ Copy visible data as CSV
+
{
+ const str = String(value ?? '');
+ return str.includes(delimiter) || str.includes('"') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str;
+ };
+ const lines = [
+ this.columns.map(escape).join(delimiter),
+ ...sourceRows.map(row => this.columns.map(col => escape(row[col])).join(delimiter))
+ ];
+ const text = lines.join('\n');
+ // TODO: Refactor the duplicated copy-to-clipboard logics
+ const fallbackCopy = () => {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ };
+ // navigator.clipboard is undefined in non-secure contexts (e.g. plain HTTP),
+ // where writeText would throw synchronously before the catch could run.
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).catch(fallbackCopy);
+ } else {
+ fallbackCopy();
+ }
+ }
+
onChangeType(type: ColType, col: string) {
this.getColOptionOrThrow(col).type = type;
this.filterRows();
diff --git a/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html b/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
index 6b34977f8f9..1e78a6e3ee4 100644
--- a/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
+++ b/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
@@ -87,9 +87,12 @@
Toggle Dropdown
-