This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new abbc57a6aa Issue #7891 : Add named table views to TableView (#7946)
abbc57a6aa is described below

commit abbc57a6aabdc053a89103fb3808a0eb92994074
Author: Matt Casters <[email protected]>
AuthorDate: Sat Aug 15 10:41:23 2026 +0200

    Issue #7891 : Add named table views to TableView (#7946)
    
    Allow users to define reusable named column subsets and order on
    preview grids and other TableView instances. Views are applied
    in-place by hiding unused columns, persisted in the active
    AuditManager, and documented in the user manual.
    
    Fixes #7891
---
 .../images/hop-gui/pipeline/table-views-dialog.png | Bin 0 -> 420612 bytes
 .../pages/hop-gui/perspective-configuration.adoc   |   2 +-
 .../pages/pipeline/run-preview-debug-pipeline.adoc |  26 +
 .../modules/ROOT/pages/protips/index.adoc          |   5 +
 .../java/org/apache/hop/history/AuditStateMap.java |  13 +
 .../org/apache/hop/history/AuditManagerTest.java   |  28 +
 .../ui/core/dialog/TableViewColumnViewDialog.java  | 582 +++++++++++++++++++++
 .../org/apache/hop/ui/core/widget/TableView.java   | 300 +++++++++++
 .../hop/ui/core/widget/TableViewColumnView.java    |  41 +-
 .../ui/core/widget/TableViewColumnViewManager.java | 149 ++++++
 .../hop/ui/core/widget/TableViewColumnViews.java   |  63 +++
 .../core/dialog/messages/messages_en_US.properties |  25 +
 .../core/widget/messages/messages_en_US.properties |   4 +
 .../widget/TableViewColumnViewManagerTest.java     |  78 +++
 .../ui/core/widget/TableViewColumnViewsTest.java   |  75 +++
 15 files changed, 1361 insertions(+), 30 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
new file mode 100644
index 0000000000..bc33b81d71
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
index 263d5a1e4b..2b39b2f5b0 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
@@ -113,7 +113,7 @@ The `Look & Feel` configuration options allow you to 
configure various aspects o
 |Canvas Grid Size|default grid size to use for workflows and pipelines in the 
Hop GUI editor |16
 |Show Canvas Grid|show the grid (dotted) for workflows and pipelines in Hop 
GUI |no
 |Hide the menu bar|Do not show the menu bar. If enabled (default), the menu 
options are available from the Apache Hop icon in Hop GUI's upper left corner. 
|yes
-|Show a toolbar above tables|Show a toolbar with options to cut/copy/paste, 
move lines up/down etc in table views (e.g. in preview dialogs, transform 
configuration options)|yes
+|Show a toolbar above tables|Show a toolbar with options to cut/copy/paste, 
move lines up/down, navigate to a column, manage 
xref:pipeline/run-preview-debug-pipeline.adoc#table-views[table views] etc in 
table grids (e.g. in preview dialogs, transform configuration options)|yes
 |Dark mode|use dark mode. On Hop Web and Windows you can toggle this; on macOS 
and other desktop platforms it follows the operating system theme. Changing it 
in Hop Web reloads the page to apply the theme. |N/A
 |Preferred Language
 a|the default language to use in Hop GUI.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
index b5e979618b..55f91350fc 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
@@ -106,3 +106,29 @@ To change the pipeline run configuration, click 
**Configure**.
 The collected rows are shown in the preview window when execution pauses or 
finishes.
 
 image::hop-gui/pipeline/preview-pipeline.png[Preview pipeline,75%,align="left"]
+
+[[table-views]]
+== Table views
+
+Preview windows (and other table grids in Hop Gui) can contain a large number 
of columns.
+A **table view** is a named subset of those columns, in the order you want to 
see them.
+The underlying rows are not changed: hidden columns stay in the data and are 
still exported, copied or saved.
+
+Open the **Table views** dialog from the table toolbar 
image:getting-started/icons/view.svg[Table views,25px,align="bottom"] icon, 
from the row context menu, or from the column header context menu.
+
+image::hop-gui/pipeline/table-views-dialog.png[Table views dialog on a wide 
preview,75%,align="left"]
+
+In the dialog:
+
+* Create, rename or delete a view. **Use current layout** fills the view from 
the columns that are visible now.
+* Move fields between **Available columns** and **Columns in view**, and use 
**Move up** / **Move down** to set the display order.
+* **Apply** shows only the selected columns, in that order. **Show all 
columns** restores the full table.
+* **Save** stores the view so you can reuse it later.
+
+Views are stored per project in the Hop audit folder and are matched by column 
name.
+A view such as `customer-bk-fields` can be applied to any table that has those 
field names, not only the preview where you created it.
+
+You can also hide a single column from the column header context menu, or drag 
headers to reorder them (desktop Hop Gui).
+Those one-off changes can be captured with **Use current layout** and then 
saved as a named view.
+
+The table toolbar itself can be turned on or off with **Show a toolbar above 
tables** in the xref:hop-gui/perspective-configuration.adoc[Configuration] 
perspective.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/protips/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/protips/index.adoc
index 5a716b90ab..f0d540f4ec 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/protips/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/protips/index.adoc
@@ -41,6 +41,11 @@ 
include::../snippets/variables/hop-windows-shell-encoding.adoc[]
 CTRL-K::
 In any table view in a Hop dialog, select one or more lines and use `CTRL-K` 
to remove all lines but your selection
 
+Table views::
+On a wide preview (or any other table grid), use the view icon on the table 
toolbar to define a named subset and order of columns.
+Views are stored per project and matched by column name, so you can reuse them 
the next time you inspect similar data.
+See xref:pipeline/run-preview-debug-pipeline.adoc#table-views[Table views].
+
 CTRL-SHIFT-Click::
 Hover your mouse pointer over any pipeline action in a workflow, pipeline or 
workflow executor transform etc and use `CTRL-SHIFT-Click` to open that item in 
a new tab.
 The same behavior can be triggered by hovering over an item and hitting the 
`Z` key.
diff --git a/engine/src/main/java/org/apache/hop/history/AuditStateMap.java 
b/engine/src/main/java/org/apache/hop/history/AuditStateMap.java
index 96ab82171a..46571108e3 100644
--- a/engine/src/main/java/org/apache/hop/history/AuditStateMap.java
+++ b/engine/src/main/java/org/apache/hop/history/AuditStateMap.java
@@ -51,4 +51,17 @@ public class AuditStateMap {
   public AuditState get(String name) {
     return nameStateMap.get(name);
   }
+
+  /**
+   * Remove the audit state stored under {@code name}.
+   *
+   * @param name The name of the object whose state should be removed
+   * @return the previous state, or {@code null} if there was none
+   */
+  public AuditState remove(String name) {
+    if (nameStateMap == null || name == null) {
+      return null;
+    }
+    return nameStateMap.remove(name);
+  }
 }
diff --git a/engine/src/test/java/org/apache/hop/history/AuditManagerTest.java 
b/engine/src/test/java/org/apache/hop/history/AuditManagerTest.java
index 8fd67ca6f5..ae89eefe44 100644
--- a/engine/src/test/java/org/apache/hop/history/AuditManagerTest.java
+++ b/engine/src/test/java/org/apache/hop/history/AuditManagerTest.java
@@ -18,6 +18,7 @@ package org.apache.hop.history;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.times;
@@ -180,4 +181,31 @@ class AuditManagerTest {
     stateMap = AuditManager.getActive().loadAuditStateMap(group, type);
     assertTrue(stateMap.getNameStateMap().isEmpty(), "State should be 
cleared");
   }
+
+  @Test
+  void testRemoveState() throws HopException {
+    String group = "hop-gui";
+    String type = "table-view";
+
+    Map<String, Object> first = new HashMap<>();
+    first.put("columns", List.of("id", "name"));
+    AuditManager.storeState(
+        org.apache.hop.core.logging.LogChannel.GENERAL, group, type, "Customer 
keys", first);
+
+    Map<String, Object> second = new HashMap<>();
+    second.put("columns", List.of("order_id"));
+    AuditManager.storeState(
+        org.apache.hop.core.logging.LogChannel.GENERAL, group, type, "Order 
dates", second);
+
+    AuditStateMap stateMap = AuditManager.getActive().loadAuditStateMap(group, 
type);
+    assertEquals(2, stateMap.getNameStateMap().size(), "Both views should be 
stored");
+
+    assertNotNull(stateMap.remove("Customer keys"));
+    AuditManager.getActive().saveAuditStateMap(group, type, stateMap);
+
+    stateMap = AuditManager.getActive().loadAuditStateMap(group, type);
+    assertNull(stateMap.get("Customer keys"), "Removed view should be gone");
+    assertNotNull(stateMap.get("Order dates"), "The other view should remain");
+    assertEquals(1, stateMap.getNameStateMap().size());
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
new file mode 100644
index 0000000000..7dc035385f
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
@@ -0,0 +1,582 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.hop.ui.core.dialog;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.core.widget.TableViewColumnView;
+import org.apache.hop.ui.core.widget.TableViewColumnViewManager;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+/** Create, edit, apply and persist named {@link TableView} column views. */
+public class TableViewColumnViewDialog extends Dialog {
+  private static final Class<?> PKG = TableViewColumnViewDialog.class;
+
+  private final PropsUi props;
+  private final TableView tableView;
+  private final String[] tableColumnNames;
+  private final Set<String> tableColumnNameSet;
+  private final String group;
+
+  private final Map<String, TableViewColumnView> views = new LinkedHashMap<>();
+
+  private Shell shell;
+  private org.eclipse.swt.widgets.List wViews;
+  private org.eclipse.swt.widgets.List wAvailable;
+  private org.eclipse.swt.widgets.List wShown;
+  private Label wlStatus;
+
+  public TableViewColumnViewDialog(Shell parent, TableView tableView) {
+    super(parent, SWT.NONE);
+    this.props = PropsUi.getInstance();
+    this.tableView = tableView;
+    this.tableColumnNames = columnNames(tableView);
+    this.tableColumnNameSet = new HashSet<>(Arrays.asList(tableColumnNames));
+    String namespace;
+    try {
+      namespace = HopNamespace.getNamespace();
+    } catch (Exception e) {
+      namespace = "hop-gui";
+    }
+    this.group = namespace;
+  }
+
+  public void open() {
+    Shell parent = getParent();
+    shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE | SWT.MAX | 
SWT.MIN);
+    PropsUi.setLook(shell);
+    shell.setImage(GuiResource.getInstance().getImageView());
+    shell.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Title"));
+
+    FormLayout formLayout = new FormLayout();
+    formLayout.marginWidth = PropsUi.getFormMargin();
+    formLayout.marginHeight = PropsUi.getFormMargin();
+    shell.setLayout(formLayout);
+
+    int margin = PropsUi.getMargin();
+
+    Button wApply = new Button(shell, SWT.PUSH);
+    wApply.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Apply.Label"));
+    wApply.addListener(SWT.Selection, e -> apply());
+
+    Button wShowAll = new Button(shell, SWT.PUSH);
+    wShowAll.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.ShowAll.Label"));
+    wShowAll.addListener(SWT.Selection, e -> showAll());
+
+    Button wSave = new Button(shell, SWT.PUSH);
+    wSave.setText(BaseMessages.getString(PKG, "System.Button.Save"));
+    wSave.addListener(SWT.Selection, e -> save());
+
+    Button wClose = new Button(shell, SWT.PUSH);
+    wClose.setText(BaseMessages.getString(PKG, "System.Button.Close"));
+    wClose.addListener(SWT.Selection, e -> close());
+
+    BaseTransformDialog.positionBottomButtons(
+        shell, new Button[] {wApply, wShowAll, wSave, wClose}, margin, null);
+
+    wlStatus = new Label(shell, SWT.LEFT);
+    PropsUi.setLook(wlStatus);
+    FormData fdStatus = new FormData();
+    fdStatus.left = new FormAttachment(0, 0);
+    fdStatus.right = new FormAttachment(100, 0);
+    fdStatus.bottom = new FormAttachment(wApply, -margin);
+    wlStatus.setLayoutData(fdStatus);
+
+    Label wlViews = new Label(shell, SWT.LEFT);
+    wlViews.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Views.Label"));
+    PropsUi.setLook(wlViews);
+    FormData fdlViews = new FormData();
+    fdlViews.left = new FormAttachment(0, 0);
+    fdlViews.top = new FormAttachment(0, 0);
+    wlViews.setLayoutData(fdlViews);
+
+    Composite viewButtons = new Composite(shell, SWT.NONE);
+    PropsUi.setLook(viewButtons);
+    GridLayout buttonLayout = new GridLayout(4, false);
+    buttonLayout.marginWidth = 0;
+    buttonLayout.marginHeight = 0;
+    viewButtons.setLayout(buttonLayout);
+    FormData fdViewButtons = new FormData();
+    fdViewButtons.left = new FormAttachment(0, 0);
+    fdViewButtons.top = new FormAttachment(wlViews, margin);
+    fdViewButtons.right = new FormAttachment(100, 0);
+    viewButtons.setLayoutData(fdViewButtons);
+
+    Button wNew = new Button(viewButtons, SWT.PUSH);
+    wNew.setText(BaseMessages.getString(PKG, "System.Button.New"));
+    wNew.addListener(SWT.Selection, e -> newView());
+
+    Button wRename = new Button(viewButtons, SWT.PUSH);
+    wRename.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Rename.Label"));
+    wRename.addListener(SWT.Selection, e -> renameView());
+
+    Button wDelete = new Button(viewButtons, SWT.PUSH);
+    wDelete.setText(BaseMessages.getString(PKG, "System.Button.Delete"));
+    wDelete.addListener(SWT.Selection, e -> deleteView());
+
+    Button wUseCurrent = new Button(viewButtons, SWT.PUSH);
+    wUseCurrent.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.UseCurrent.Label"));
+    wUseCurrent.addListener(SWT.Selection, e -> useCurrentLayout());
+
+    wViews = new org.eclipse.swt.widgets.List(shell, SWT.BORDER | SWT.SINGLE | 
SWT.V_SCROLL);
+    PropsUi.setLook(wViews);
+    FormData fdViews = new FormData();
+    fdViews.left = new FormAttachment(0, 0);
+    fdViews.top = new FormAttachment(viewButtons, margin);
+    fdViews.right = new FormAttachment(100, 0);
+    fdViews.height = (int) (120 * PropsUi.getNativeZoomFactor());
+    wViews.setLayoutData(fdViews);
+    wViews.addListener(SWT.Selection, e -> viewSelected());
+
+    SashForm sash = new SashForm(shell, SWT.HORIZONTAL);
+    FormData fdSash = new FormData();
+    fdSash.left = new FormAttachment(0, 0);
+    fdSash.top = new FormAttachment(wViews, margin);
+    fdSash.right = new FormAttachment(100, 0);
+    fdSash.bottom = new FormAttachment(wlStatus, -margin);
+    sash.setLayoutData(fdSash);
+
+    Composite left =
+        createListPane(
+            sash, BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Available.Label"));
+    wAvailable = (org.eclipse.swt.widgets.List) left.getData("list");
+    wAvailable.addListener(SWT.DefaultSelection, e -> addSelected());
+
+    Composite middle = new Composite(sash, SWT.NONE);
+    PropsUi.setLook(middle);
+    middle.setLayout(new FormLayout());
+
+    Composite gButtons = new Composite(middle, SWT.NONE);
+    GridLayout grid = new GridLayout(1, true);
+    gButtons.setLayout(grid);
+    PropsUi.setLook(gButtons);
+
+    Button wAddOne = createMoveButton(gButtons, " > ", this::addSelected);
+    Button wAddAll = createMoveButton(gButtons, " >> ", this::addAll);
+    Button wRemoveOne = createMoveButton(gButtons, " < ", 
this::removeSelected);
+    Button wRemoveAll = createMoveButton(gButtons, " << ", this::removeAll);
+    wAddOne.setToolTipText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.AddOne.ToolTip"));
+    wAddAll.setToolTipText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.AddAll.ToolTip"));
+    wRemoveOne.setToolTipText(
+        BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.RemoveOne.ToolTip"));
+    wRemoveAll.setToolTipText(
+        BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.RemoveAll.ToolTip"));
+
+    FormData fdButtons = new FormData();
+    wAddAll.pack();
+    fdButtons.left = new FormAttachment(50, -(wAddAll.getSize().x / 2) - 5);
+    fdButtons.top = new FormAttachment(20, 0);
+    gButtons.setLayoutData(fdButtons);
+
+    Composite right =
+        createListPane(sash, BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Shown.Label"));
+    wShown = (org.eclipse.swt.widgets.List) right.getData("list");
+    wShown.addListener(SWT.DefaultSelection, e -> removeSelected());
+
+    Composite orderButtons = new Composite(right, SWT.NONE);
+    PropsUi.setLook(orderButtons);
+    GridLayout orderLayout = new GridLayout(2, true);
+    orderLayout.marginWidth = 0;
+    orderLayout.marginHeight = 0;
+    orderButtons.setLayout(orderLayout);
+    FormData fdOrder = new FormData();
+    fdOrder.left = new FormAttachment(0, 0);
+    fdOrder.right = new FormAttachment(100, 0);
+    fdOrder.bottom = new FormAttachment(100, 0);
+    orderButtons.setLayoutData(fdOrder);
+
+    Button wUp = new Button(orderButtons, SWT.PUSH);
+    wUp.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.MoveUp.Label"));
+    wUp.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+    wUp.addListener(SWT.Selection, e -> moveShown(-1));
+
+    Button wDown = new Button(orderButtons, SWT.PUSH);
+    wDown.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.MoveDown.Label"));
+    wDown.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+    wDown.addListener(SWT.Selection, e -> moveShown(1));
+
+    ((FormData) wShown.getLayoutData()).bottom = new 
FormAttachment(orderButtons, -margin);
+
+    sash.setWeights(new int[] {42, 16, 42});
+
+    shell.addListener(SWT.Close, e -> close());
+
+    loadViews();
+    refreshColumnLists(java.util.List.of());
+    updateStatus();
+
+    BaseTransformDialog.setSize(shell);
+    shell.open();
+
+    Display display = shell.getDisplay();
+    while (!shell.isDisposed()) {
+      if (!display.readAndDispatch()) {
+        display.sleep();
+      }
+    }
+  }
+
+  private Composite createListPane(SashForm sash, String title) {
+    Composite pane = new Composite(sash, SWT.NONE);
+    PropsUi.setLook(pane);
+    pane.setLayout(new FormLayout());
+
+    Label label = new Label(pane, SWT.LEFT);
+    label.setText(title);
+    PropsUi.setLook(label);
+    FormData fdLabel = new FormData();
+    fdLabel.left = new FormAttachment(0, 0);
+    fdLabel.top = new FormAttachment(0, 0);
+    label.setLayoutData(fdLabel);
+
+    org.eclipse.swt.widgets.List list =
+        new org.eclipse.swt.widgets.List(
+            pane, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL);
+    PropsUi.setLook(list);
+    FormData fdList = new FormData();
+    fdList.left = new FormAttachment(0, 0);
+    fdList.top = new FormAttachment(label, 0);
+    fdList.right = new FormAttachment(100, 0);
+    fdList.bottom = new FormAttachment(100, 0);
+    list.setLayoutData(fdList);
+    pane.setData("list", list);
+    return pane;
+  }
+
+  private Button createMoveButton(Composite parent, String text, Runnable 
action) {
+    Button button = new Button(parent, SWT.PUSH);
+    button.setText(text);
+    button.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+    button.addListener(SWT.Selection, e -> action.run());
+    return button;
+  }
+
+  private void loadViews() {
+    views.clear();
+    for (TableViewColumnView view : TableViewColumnViewManager.list(group)) {
+      views.put(view.getName(), view);
+    }
+    refreshViewList(null);
+  }
+
+  private void refreshViewList(String selectName) {
+    String[] names = views.keySet().toArray(new String[0]);
+    Arrays.sort(names, String.CASE_INSENSITIVE_ORDER);
+    wViews.setItems(names);
+    if (selectName != null) {
+      int index = Const.indexOfString(selectName, names);
+      if (index >= 0) {
+        wViews.setSelection(index);
+      }
+    }
+  }
+
+  private void viewSelected() {
+    String name = selectedViewName();
+    if (name == null) {
+      return;
+    }
+    TableViewColumnView view = views.get(name);
+    java.util.List<String> shown =
+        view != null && view.getColumnNames() != null
+            ? new ArrayList<>(view.getColumnNames())
+            : new ArrayList<>();
+    refreshColumnLists(shown);
+    updateStatus();
+  }
+
+  private void newView() {
+    String name = promptName("");
+    if (name == null) {
+      return;
+    }
+    if (views.containsKey(name) && !confirmOverwrite(name)) {
+      return;
+    }
+    TableViewColumnView view = new TableViewColumnView(name, new 
ArrayList<>());
+    views.put(name, view);
+    refreshViewList(name);
+    refreshColumnLists(java.util.List.of());
+    updateStatus();
+  }
+
+  private void renameView() {
+    String oldName = selectedViewName();
+    if (oldName == null) {
+      return;
+    }
+    String newName = promptName(oldName);
+    if (newName == null || newName.equals(oldName)) {
+      return;
+    }
+    if (views.containsKey(newName) && !confirmOverwrite(newName)) {
+      return;
+    }
+    TableViewColumnView view = views.remove(oldName);
+    if (view == null) {
+      view = new TableViewColumnView();
+    }
+    view.setName(newName);
+    view.setColumnNames(currentShownColumns());
+    views.remove(newName);
+    views.put(newName, view);
+    TableViewColumnViewManager.delete(group, oldName);
+    TableViewColumnViewManager.save(group, view);
+    refreshViewList(newName);
+  }
+
+  private void deleteView() {
+    String name = selectedViewName();
+    if (name == null) {
+      return;
+    }
+    MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+    box.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Delete.Title"));
+    box.setMessage(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Delete.Message", name));
+    if ((box.open() & SWT.YES) == 0) {
+      return;
+    }
+    views.remove(name);
+    TableViewColumnViewManager.delete(group, name);
+    refreshViewList(null);
+    refreshColumnLists(java.util.List.of());
+    updateStatus();
+  }
+
+  private void useCurrentLayout() {
+    refreshColumnLists(tableView.getVisibleColumnNamesInOrder());
+    updateStatus();
+  }
+
+  private void addSelected() {
+    moveItems(wAvailable, wShown, wAvailable.getSelection());
+  }
+
+  private void addAll() {
+    moveItems(wAvailable, wShown, wAvailable.getItems());
+  }
+
+  private void removeSelected() {
+    moveItems(wShown, wAvailable, wShown.getSelection());
+  }
+
+  private void removeAll() {
+    moveItems(wShown, wAvailable, wShown.getItems());
+  }
+
+  private void moveItems(
+      org.eclipse.swt.widgets.List from, org.eclipse.swt.widgets.List to, 
String[] items) {
+    for (String item : items) {
+      if (from.indexOf(item) >= 0) {
+        from.remove(item);
+      }
+      boolean addToAvailable = to == wAvailable && 
tableColumnNameSet.contains(item);
+      boolean addToShown = to == wShown;
+      if ((addToAvailable || addToShown) && to.indexOf(item) < 0) {
+        to.add(item);
+      }
+    }
+    updateStatus();
+  }
+
+  private void moveShown(int delta) {
+    int[] selection = wShown.getSelectionIndices();
+    if (selection.length != 1) {
+      return;
+    }
+    int from = selection[0];
+    int to = from + delta;
+    if (to < 0 || to >= wShown.getItemCount()) {
+      return;
+    }
+    String[] items = wShown.getItems();
+    String moving = items[from];
+    items[from] = items[to];
+    items[to] = moving;
+    wShown.setItems(items);
+    wShown.setSelection(to);
+  }
+
+  private void apply() {
+    java.util.List<String> shown = currentShownColumns();
+    if (shown.isEmpty()) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
+      box.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Title"));
+      box.setMessage(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.EmptyView.Message"));
+      box.open();
+      return;
+    }
+    if (!tableView.applyColumnView(shown)) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
+      box.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Title"));
+      box.setMessage(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.NoMatch.Message"));
+      box.open();
+    }
+  }
+
+  private void showAll() {
+    tableView.resetColumnView();
+  }
+
+  private void save() {
+    String name = selectedViewName();
+    if (name == null) {
+      name = promptName("");
+      if (name == null) {
+        return;
+      }
+      if (views.containsKey(name) && !confirmOverwrite(name)) {
+        return;
+      }
+    }
+    TableViewColumnView view = views.computeIfAbsent(name, key -> new 
TableViewColumnView());
+    view.setName(name);
+    view.setColumnNames(currentShownColumns());
+    TableViewColumnViewManager.save(group, view);
+    views.put(name, view);
+    refreshViewList(name);
+  }
+
+  private void close() {
+    props.setScreen(new WindowProperty(shell));
+    shell.dispose();
+  }
+
+  private void refreshColumnLists(java.util.List<String> shownNames) {
+    java.util.List<String> shown = new ArrayList<>();
+    Set<String> shownSet = new HashSet<>();
+    for (String name : shownNames) {
+      if (StringUtils.isEmpty(name) || shownSet.contains(name)) {
+        continue;
+      }
+      shown.add(name);
+      shownSet.add(name);
+    }
+    wShown.setItems(shown.toArray(new String[0]));
+
+    java.util.List<String> available = new ArrayList<>();
+    for (String name : tableColumnNames) {
+      if (!shownSet.contains(name)) {
+        available.add(name);
+      }
+    }
+    wAvailable.setItems(available.toArray(new String[0]));
+  }
+
+  private void updateStatus() {
+    java.util.List<String> shown = currentShownColumns();
+    int matching = 0;
+    int missing = 0;
+    for (String name : shown) {
+      if (tableColumnNameSet.contains(name)) {
+        matching++;
+      } else {
+        missing++;
+      }
+    }
+    String status =
+        BaseMessages.getString(
+            PKG,
+            "TableViewColumnViewDialog.Status.Visible",
+            Integer.toString(matching),
+            Integer.toString(tableColumnNames.length));
+    if (missing > 0) {
+      status +=
+          "  "
+              + BaseMessages.getString(
+                  PKG, "TableViewColumnViewDialog.Status.Missing", 
Integer.toString(missing));
+    }
+    wlStatus.setText(status);
+  }
+
+  private java.util.List<String> currentShownColumns() {
+    return new ArrayList<>(Arrays.asList(wShown.getItems()));
+  }
+
+  private String selectedViewName() {
+    String[] selection = wViews.getSelection();
+    if (selection == null || selection.length == 0) {
+      return null;
+    }
+    return selection[0];
+  }
+
+  private String promptName(String initial) {
+    EnterStringDialog dialog =
+        new EnterStringDialog(
+            shell,
+            Const.NVL(initial, ""),
+            BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Name.Title"),
+            BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Name.Message"));
+    String name = dialog.open();
+    if (name == null) {
+      return null;
+    }
+    name = name.trim();
+    if (name.isEmpty()) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
+      box.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Title"));
+      box.setMessage(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Name.Empty"));
+      box.open();
+      return null;
+    }
+    return name;
+  }
+
+  private boolean confirmOverwrite(String name) {
+    MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+    box.setText(BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Overwrite.Title"));
+    box.setMessage(
+        BaseMessages.getString(PKG, 
"TableViewColumnViewDialog.Overwrite.Message", name));
+    return (box.open() & SWT.YES) != 0;
+  }
+
+  private static String[] columnNames(TableView tableView) {
+    ColumnInfo[] columns = tableView.getColumns();
+    String[] names = new String[columns.length];
+    for (int i = 0; i < columns.length; i++) {
+      names[i] = Const.NVL(columns[i].getName(), "");
+    }
+    return names;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
index e12864812b..7bf3d2b308 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
@@ -55,6 +55,7 @@ import org.apache.hop.ui.core.dialog.EnterConditionDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.dialog.TableViewColumnViewDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
@@ -151,6 +152,7 @@ public class TableView extends Composite {
       "tableview-toolbar-10320-filtered-selection";
   public static final String ID_TOOLBAR_NAVIGATE_TO_COLUMN =
       "tableview-toolbar-10330-navigate-to-column";
+  public static final String ID_TOOLBAR_TABLE_VIEWS = 
"tableview-toolbar-10340-table-views";
   public static final String ID_TOOLBAR_COPY_SELECTED = 
"tableview-toolbar-10400-copy-selected";
   public static final String ID_TOOLBAR_PASTE_TO_TABLE = 
"tableview-toolbar-10410-paste-to-table";
   public static final String ID_TOOLBAR_CUT_SELECTED = 
"tableview-toolbar-10420-cut-selected";
@@ -235,6 +237,8 @@ public class TableView extends Composite {
   private boolean addIndexColumn = true;
   private final Color nullTextColor;
   private final List<String> removeToolItems;
+  private final Set<Integer> hiddenDataColumns = new HashSet<>();
+  private int[] rememberedWidths;
 
   /**
    * Create a table to add to a dialog
@@ -577,6 +581,9 @@ public class TableView extends Composite {
         tableColumn[i + 1].setAlignment(SWT.RIGHT);
       }
       tableColumn[i + 1].pack();
+      if (!EnvironmentUtils.getInstance().isWeb()) {
+        tableColumn[i + 1].setMoveable(true);
+      }
     }
 
     table.setHeaderVisible(true);
@@ -735,6 +742,7 @@ public class TableView extends Composite {
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_CLEAR_SELECTION, hasRows);
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_FILTERED_SELECTION, hasRows);
     toolbarWidgets.enableToolbarItem(ID_TOOLBAR_NAVIGATE_TO_COLUMN, 
columns.length > 0);
+    toolbarWidgets.enableToolbarItem(ID_TOOLBAR_TABLE_VIEWS, columns.length > 
0);
   }
 
   private MouseListener createTableMouseListener() {
@@ -1462,6 +1470,16 @@ public class TableView extends Composite {
           OsHelper.customizeMenuitemText(
               BaseMessages.getString(PKG, "TableView.menu.NavigateToColumn")));
       miNavigateToColumn.addListener(SWT.Selection, e -> navigateToColumn());
+    }
+
+    if (!removeToolItems.contains(ID_TOOLBAR_TABLE_VIEWS)) {
+      MenuItem miTableViews = new MenuItem(mRow, SWT.NONE);
+      miTableViews.setText(
+          OsHelper.customizeMenuitemText(BaseMessages.getString(PKG, 
"TableView.menu.TableViews")));
+      miTableViews.setImage(GuiResource.getInstance().getImageView());
+      miTableViews.addListener(SWT.Selection, e -> editTableViews());
+      new MenuItem(mRow, SWT.SEPARATOR);
+    } else if (!removeToolItems.contains(ID_TOOLBAR_NAVIGATE_TO_COLUMN)) {
       new MenuItem(mRow, SWT.SEPARATOR);
     }
 
@@ -1536,6 +1554,7 @@ public class TableView extends Composite {
     }
 
     table.setMenu(mRow);
+    addHeaderContextMenu(mRow);
   }
 
   protected void addToolbar() {
@@ -3539,6 +3558,12 @@ public class TableView extends Composite {
 
     for (int c = 0; c < table.getColumnCount(); c++) {
       TableColumn tc = table.getColumn(c);
+      if (c > 0 && hiddenDataColumns.contains(c - 1)) {
+        if (tc.getWidth() != 0) {
+          tc.setWidth(0);
+        }
+        continue;
+      }
       int max = 0;
       if (header) {
         max = TextSizeUtilFacade.textExtent(tc.getText()).x + extraForMargin;
@@ -4230,6 +4255,9 @@ public class TableView extends Composite {
     }
 
     // tableColumn[0] is the row-number (#) column; data columns start at 
index 1.
+    if (hiddenDataColumns.contains(index)) {
+      showDataColumn(index);
+    }
     TableColumn tableCol = tableColumn[index + 1];
     if (tableCol != null && !tableCol.isDisposed()) {
       table.showColumn(tableCol);
@@ -4237,6 +4265,278 @@ public class TableView extends Composite {
     }
   }
 
+  @GuiToolbarElement(
+      root = ID_TOOLBAR,
+      id = ID_TOOLBAR_TABLE_VIEWS,
+      image = "ui/images/view.svg",
+      toolTip = "i18n::TableView.ToolBarWidget.TableViews.ToolTip")
+  public void editTableViews() {
+    if (columns.length == 0) {
+      return;
+    }
+    new TableViewColumnViewDialog(getShell(), this).open();
+  }
+
+  /**
+   * Hide and reorder data columns so that only {@code columnNames} stay 
visible, in that order.
+   * Matching is by column name. Cell data is left in place.
+   *
+   * @return {@code true} if at least one column matched
+   */
+  public boolean applyColumnView(List<String> columnNames) {
+    if (table == null || table.isDisposed() || columns.length == 0) {
+      return false;
+    }
+    String[] available = new String[columns.length];
+    for (int i = 0; i < columns.length; i++) {
+      available[i] = Const.NVL(columns[i].getName(), "");
+    }
+    List<Integer> visible = 
TableViewColumnViews.resolveColumnIndices(available, columnNames);
+    if (visible.isEmpty()) {
+      return false;
+    }
+    ensureRememberedWidths();
+    hiddenDataColumns.clear();
+    Set<Integer> visibleSet = new HashSet<>(visible);
+    for (int i = 0; i < columns.length; i++) {
+      if (!visibleSet.contains(i)) {
+        hiddenDataColumns.add(i);
+      }
+    }
+    applyHiddenAndOrder(visible);
+    return true;
+  }
+
+  /** Restore every data column to its original order and remembered width. */
+  public void resetColumnView() {
+    if (table == null || table.isDisposed()) {
+      return;
+    }
+    hiddenDataColumns.clear();
+    for (int i = 1; i < tableColumn.length; i++) {
+      TableColumn tc = tableColumn[i];
+      if (tc == null || tc.isDisposed()) {
+        continue;
+      }
+      tc.setResizable(true);
+      int restore = rememberedWidths != null ? rememberedWidths[i] : 0;
+      if (restore > 0) {
+        tc.setWidth(restore);
+      } else if (tc.getWidth() <= 0) {
+        tc.pack();
+      }
+    }
+    setColumnOrderSafe(naturalColumnOrder());
+  }
+
+  /**
+   * Visible data-column names in current visual order (the # column is 
omitted). Hidden columns
+   * (width 0) are skipped.
+   */
+  public List<String> getVisibleColumnNamesInOrder() {
+    List<String> names = new ArrayList<>();
+    if (table == null || table.isDisposed()) {
+      return names;
+    }
+    for (int tableIdx : getColumnOrderSafe()) {
+      if (tableIdx <= 0 || tableIdx > columns.length) {
+        continue;
+      }
+      int dataIdx = tableIdx - 1;
+      if (hiddenDataColumns.contains(dataIdx)) {
+        continue;
+      }
+      TableColumn tc = tableColumn[tableIdx];
+      if (tc == null || tc.isDisposed() || tc.getWidth() <= 0) {
+        continue;
+      }
+      names.add(Const.NVL(columns[dataIdx].getName(), ""));
+    }
+    return names;
+  }
+
+  public void hideDataColumn(int dataIndex) {
+    if (dataIndex < 0 || dataIndex >= columns.length) {
+      return;
+    }
+    if (table == null || table.isDisposed()) {
+      return;
+    }
+    ensureRememberedWidths();
+    rememberWidth(dataIndex + 1);
+    hiddenDataColumns.add(dataIndex);
+    TableColumn tc = tableColumn[dataIndex + 1];
+    if (tc != null && !tc.isDisposed()) {
+      tc.setWidth(0);
+      tc.setResizable(false);
+    }
+  }
+
+  private void showDataColumn(int dataIndex) {
+    if (dataIndex < 0 || dataIndex >= columns.length) {
+      return;
+    }
+    hiddenDataColumns.remove(dataIndex);
+    TableColumn tc = tableColumn[dataIndex + 1];
+    if (tc == null || tc.isDisposed()) {
+      return;
+    }
+    tc.setResizable(true);
+    int restore = rememberedWidths != null ? rememberedWidths[dataIndex + 1] : 
0;
+    if (restore > 0) {
+      tc.setWidth(restore);
+    } else if (tc.getWidth() <= 0) {
+      tc.pack();
+    }
+  }
+
+  private void applyHiddenAndOrder(List<Integer> visibleDataIndices) {
+    for (int i = 0; i < columns.length; i++) {
+      TableColumn tc = tableColumn[i + 1];
+      if (tc == null || tc.isDisposed()) {
+        continue;
+      }
+      if (hiddenDataColumns.contains(i)) {
+        rememberWidth(i + 1);
+        tc.setWidth(0);
+        tc.setResizable(false);
+      } else {
+        tc.setResizable(true);
+        if (tc.getWidth() <= 0) {
+          int restore = rememberedWidths != null ? rememberedWidths[i + 1] : 0;
+          tc.setWidth(restore > 0 ? restore : 50);
+        }
+      }
+    }
+
+    int[] order = new int[tableColumn.length];
+    int pos = 0;
+    order[pos++] = 0;
+    for (int dataIdx : visibleDataIndices) {
+      order[pos++] = dataIdx + 1;
+    }
+    for (int i = 0; i < columns.length; i++) {
+      if (hiddenDataColumns.contains(i)) {
+        order[pos++] = i + 1;
+      }
+    }
+    setColumnOrderSafe(order);
+  }
+
+  private void ensureRememberedWidths() {
+    if (rememberedWidths != null && rememberedWidths.length == 
tableColumn.length) {
+      return;
+    }
+    rememberedWidths = new int[tableColumn.length];
+    for (int i = 0; i < tableColumn.length; i++) {
+      if (tableColumn[i] != null && !tableColumn[i].isDisposed()) {
+        rememberedWidths[i] = tableColumn[i].getWidth();
+      }
+    }
+  }
+
+  private void rememberWidth(int tableColIndex) {
+    ensureRememberedWidths();
+    TableColumn tc = tableColumn[tableColIndex];
+    if (tc == null || tc.isDisposed()) {
+      return;
+    }
+    int width = tc.getWidth();
+    if (width > 0) {
+      rememberedWidths[tableColIndex] = width;
+    }
+  }
+
+  private int[] naturalColumnOrder() {
+    int[] order = new int[tableColumn.length];
+    for (int i = 0; i < order.length; i++) {
+      order[i] = i;
+    }
+    return order;
+  }
+
+  private int[] getColumnOrderSafe() {
+    try {
+      int[] order = table.getColumnOrder();
+      if (order != null && order.length == tableColumn.length) {
+        return order;
+      }
+    } catch (Exception e) {
+      // RAP / some SWT ports do not implement column order
+    }
+    return naturalColumnOrder();
+  }
+
+  private void setColumnOrderSafe(int[] order) {
+    try {
+      table.setColumnOrder(order);
+    } catch (Exception e) {
+      // RAP / some SWT ports do not implement column order
+    }
+  }
+
+  private void addHeaderContextMenu(Menu rowMenu) {
+    Menu headerMenu = new Menu(table);
+
+    MenuItem miHide = new MenuItem(headerMenu, SWT.NONE);
+    miHide.setText(
+        OsHelper.customizeMenuitemText(BaseMessages.getString(PKG, 
"TableView.menu.HideColumn")));
+    final int[] headerDataColumn = {-1};
+    miHide.addListener(
+        SWT.Selection,
+        e -> {
+          if (headerDataColumn[0] >= 0) {
+            hideDataColumn(headerDataColumn[0]);
+          }
+        });
+
+    MenuItem miShowAll = new MenuItem(headerMenu, SWT.NONE);
+    miShowAll.setText(
+        OsHelper.customizeMenuitemText(
+            BaseMessages.getString(PKG, "TableView.menu.ShowAllColumns")));
+    miShowAll.addListener(SWT.Selection, e -> resetColumnView());
+
+    if (!removeToolItems.contains(ID_TOOLBAR_TABLE_VIEWS)) {
+      new MenuItem(headerMenu, SWT.SEPARATOR);
+      MenuItem miTableViews = new MenuItem(headerMenu, SWT.NONE);
+      miTableViews.setText(
+          OsHelper.customizeMenuitemText(BaseMessages.getString(PKG, 
"TableView.menu.TableViews")));
+      miTableViews.setImage(GuiResource.getInstance().getImageView());
+      miTableViews.addListener(SWT.Selection, e -> editTableViews());
+    }
+
+    table.addListener(
+        SWT.MenuDetect,
+        event -> {
+          Point pt = table.toControl(event.x, event.y);
+          boolean onHeader = pt.y >= 0 && pt.y < table.getHeaderHeight();
+          if (onHeader) {
+            headerDataColumn[0] = findDataColumnAtX(pt.x);
+            miHide.setEnabled(headerDataColumn[0] >= 0);
+            table.setMenu(headerMenu);
+          } else {
+            table.setMenu(rowMenu);
+          }
+        });
+  }
+
+  private int findDataColumnAtX(int x) {
+    int pos = 0;
+    ScrollBar hBar = table.getHorizontalBar();
+    if (hBar != null && !hBar.isDisposed()) {
+      pos -= hBar.getSelection();
+    }
+    for (int tableIdx : getColumnOrderSafe()) {
+      TableColumn tc = table.getColumn(tableIdx);
+      int width = tc.getWidth();
+      if (x >= pos && x < pos + width) {
+        return tableIdx == 0 ? -1 : tableIdx - 1;
+      }
+      pos += width;
+    }
+    return -1;
+  }
+
   public IRowMeta getRowWithoutValues() {
     IRowMeta f = new RowMeta();
     f.addValueMeta(new ValueMetaInteger("#"));
diff --git a/engine/src/main/java/org/apache/hop/history/AuditStateMap.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
similarity index 52%
copy from engine/src/main/java/org/apache/hop/history/AuditStateMap.java
copy to ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
index 96ab82171a..1286aeeda3 100644
--- a/engine/src/main/java/org/apache/hop/history/AuditStateMap.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
@@ -14,41 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.hop.history;
+package org.apache.hop.ui.core.widget;
 
-import java.util.HashMap;
-import java.util.Map;
+import java.util.ArrayList;
+import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
 
-@Setter
+/** A named, reusable subset and order of {@link TableView} columns. */
 @Getter
-public class AuditStateMap {
-
-  private Map<String, AuditState> nameStateMap;
-
-  public AuditStateMap() {
-    nameStateMap = new HashMap<>();
-  }
-
-  public AuditStateMap(Map<String, AuditState> nameStateMap) {
-    this.nameStateMap = nameStateMap;
-  }
+@Setter
+public class TableViewColumnView {
+  private String name;
+  private List<String> columnNames = new ArrayList<>();
 
-  public void add(AuditState auditState) {
-    AuditState existing = get(auditState.getName());
-    if (existing != null) {
-      // Add the states to the existing states
-      //
-      existing.getStateMap().putAll(auditState.getStateMap());
-    } else {
-      // Store a new set of properties
-      //
-      nameStateMap.put(auditState.getName(), auditState);
-    }
-  }
+  public TableViewColumnView() {}
 
-  public AuditState get(String name) {
-    return nameStateMap.get(name);
+  public TableViewColumnView(String name, List<String> columnNames) {
+    this.name = name;
+    this.columnNames = columnNames != null ? new ArrayList<>(columnNames) : 
new ArrayList<>();
   }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
new file mode 100644
index 0000000000..76d206544d
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.hop.ui.core.widget;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+import org.apache.hop.history.AuditStateMap;
+
+/**
+ * Load and store named {@link TableView} column views via the active {@link 
AuditManager}.
+ *
+ * <p>Each view is one {@link AuditState} under type {@link #AUDIT_TYPE}. The 
group is the current
+ * project namespace.
+ */
+public final class TableViewColumnViewManager {
+  public static final String AUDIT_TYPE = "table-view";
+  public static final String STATE_COLUMNS = "columns";
+
+  private TableViewColumnViewManager() {}
+
+  public static List<TableViewColumnView> list(String group) {
+    List<TableViewColumnView> views = new ArrayList<>();
+    if (StringUtils.isEmpty(group)) {
+      return views;
+    }
+    try {
+      AuditStateMap stateMap = 
AuditManager.getActive().loadAuditStateMap(group, AUDIT_TYPE);
+      if (stateMap == null || stateMap.getNameStateMap() == null) {
+        return views;
+      }
+      for (AuditState state : stateMap.getNameStateMap().values()) {
+        TableViewColumnView view = fromState(state);
+        if (view != null && StringUtils.isNotEmpty(view.getName())) {
+          views.add(view);
+        }
+      }
+      views.sort(
+          Comparator.comparing(
+              view -> view.getName() == null ? "" : view.getName(), 
String.CASE_INSENSITIVE_ORDER));
+    } catch (Exception e) {
+      LogChannel.UI.logError(
+          "Unable to list table views from audit manager type " + AUDIT_TYPE + 
" in group " + group,
+          e);
+    }
+    return views;
+  }
+
+  public static TableViewColumnView load(String group, String name) {
+    if (StringUtils.isEmpty(group) || StringUtils.isEmpty(name)) {
+      return null;
+    }
+    try {
+      AuditState state = AuditManager.getActive().retrieveState(group, 
AUDIT_TYPE, name);
+      return fromState(state);
+    } catch (Exception e) {
+      LogChannel.UI.logError(
+          "Unable to load table view '"
+              + name
+              + "' from audit manager type "
+              + AUDIT_TYPE
+              + " in group "
+              + group,
+          e);
+      return null;
+    }
+  }
+
+  public static void save(String group, TableViewColumnView view) {
+    if (StringUtils.isEmpty(group) || view == null || 
StringUtils.isEmpty(view.getName())) {
+      return;
+    }
+    Map<String, Object> stateMap = new HashMap<>();
+    List<String> columns =
+        view.getColumnNames() != null ? new ArrayList<>(view.getColumnNames()) 
: new ArrayList<>();
+    stateMap.put(STATE_COLUMNS, columns);
+    AuditManager.storeState(LogChannel.UI, group, AUDIT_TYPE, view.getName(), 
stateMap);
+  }
+
+  public static void delete(String group, String name) {
+    if (StringUtils.isEmpty(group) || StringUtils.isEmpty(name)) {
+      return;
+    }
+    try {
+      AuditStateMap stateMap = 
AuditManager.getActive().loadAuditStateMap(group, AUDIT_TYPE);
+      if (stateMap == null) {
+        return;
+      }
+      stateMap.remove(name);
+      AuditManager.getActive().saveAuditStateMap(group, AUDIT_TYPE, stateMap);
+    } catch (Exception e) {
+      LogChannel.UI.logError(
+          "Unable to delete table view '"
+              + name
+              + "' from audit manager type "
+              + AUDIT_TYPE
+              + " in group "
+              + group,
+          e);
+    }
+  }
+
+  static TableViewColumnView fromState(AuditState state) {
+    if (state == null || StringUtils.isEmpty(state.getName())) {
+      return null;
+    }
+    TableViewColumnView view = new TableViewColumnView();
+    view.setName(state.getName());
+    view.setColumnNames(extractStringList(state, STATE_COLUMNS));
+    return view;
+  }
+
+  static List<String> extractStringList(AuditState state, String key) {
+    List<String> columns = new ArrayList<>();
+    if (state == null || state.getStateMap() == null) {
+      return columns;
+    }
+    Object raw = state.getStateMap().get(key);
+    if (raw instanceof Collection<?> collection) {
+      for (Object item : collection) {
+        if (item != null) {
+          columns.add(String.valueOf(item));
+        }
+      }
+    }
+    return columns;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
new file mode 100644
index 0000000000..52833a7cf6
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.hop.ui.core.widget;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Matching helpers for named {@link TableView} column views. Kept free of SWT 
so the logic can be
+ * unit tested.
+ */
+public final class TableViewColumnViews {
+  private TableViewColumnViews() {}
+
+  /**
+   * Resolve view column names to indices into {@code availableNames}.
+   *
+   * <p>Matching is case-sensitive (Hop field names). Duplicate available 
names take the first
+   * unused match. Names that are not present are skipped.
+   *
+   * @param availableNames column names of the current table, in creation order
+   * @param viewNames column names in the desired display order
+   * @return matching indices into {@code availableNames}, in view order
+   */
+  public static List<Integer> resolveColumnIndices(
+      String[] availableNames, List<String> viewNames) {
+    List<Integer> indices = new ArrayList<>();
+    if (availableNames == null || viewNames == null) {
+      return indices;
+    }
+    boolean[] used = new boolean[availableNames.length];
+    for (String viewName : viewNames) {
+      if (viewName == null) {
+        continue;
+      }
+      for (int i = 0; i < availableNames.length; i++) {
+        if (used[i]) {
+          continue;
+        }
+        if (viewName.equals(availableNames[i])) {
+          used[i] = true;
+          indices.add(i);
+          break;
+        }
+      }
+    }
+    return indices;
+  }
+}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index 56abaa9ee6..79d1a138a7 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -319,6 +319,31 @@ ShowRowsDialog.CellTooltip.Type=Type: {0}
 ShowRowsDialog.CellTooltip.Length=Length: {0}
 ShowRowsDialog.CellTooltip.Precision=Precision: {0}
 ShowRowsDialog.CellTooltip.Origin=Origin: {0}
+TableViewColumnViewDialog.AddAll.ToolTip=Add all available columns to the view
+TableViewColumnViewDialog.AddOne.ToolTip=Add the selected columns to the view
+TableViewColumnViewDialog.Apply.Label=&Apply
+TableViewColumnViewDialog.Available.Label=Available columns
+TableViewColumnViewDialog.Delete.Message=Delete view ''{0}''?
+TableViewColumnViewDialog.Delete.Title=Delete view
+TableViewColumnViewDialog.EmptyView.Message=Select at least one column to 
apply a view.
+TableViewColumnViewDialog.MoveDown.Label=Move down
+TableViewColumnViewDialog.MoveUp.Label=Move up
+TableViewColumnViewDialog.Name.Empty=Please enter a name for the view.
+TableViewColumnViewDialog.Name.Message=Name of the table view
+TableViewColumnViewDialog.Name.Title=View name
+TableViewColumnViewDialog.NoMatch.Message=None of the fields in this view 
exist in the current table.
+TableViewColumnViewDialog.Overwrite.Message=A view named ''{0}'' already 
exists. Overwrite it?
+TableViewColumnViewDialog.Overwrite.Title=Overwrite view
+TableViewColumnViewDialog.RemoveAll.ToolTip=Remove all columns from the view
+TableViewColumnViewDialog.RemoveOne.ToolTip=Remove the selected columns from 
the view
+TableViewColumnViewDialog.Rename.Label=&Rename
+TableViewColumnViewDialog.ShowAll.Label=Show all columns
+TableViewColumnViewDialog.Shown.Label=Columns in view (display order)
+TableViewColumnViewDialog.Status.Missing={0} field(s) not in this table
+TableViewColumnViewDialog.Status.Visible={0} of {1} columns visible
+TableViewColumnViewDialog.Title=Table views
+TableViewColumnViewDialog.UseCurrent.Label=Use current layout
+TableViewColumnViewDialog.Views.Label=Views
 ProgressMonitorDialog.InitialTaskLabel=Apache Hop is handling a long running 
task
 ProgressMonitorDialog.Shell.Title=Progress...
 ProgressMonitorDialog.InitialSubTaskLabel=\ 
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
index 2d4575ff85..1b14a66d05 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
@@ -70,7 +70,10 @@ TableView.menu.InsertBeforeRow=Insert before this row
 TableView.menu.KeepSelected=Keep only selected lines\tCtrl-K
 TableView.menu.MoveDown=Move down\tCtrl-DOWN
 TableView.menu.MoveUp=Move up\tCtrl-UP
+TableView.menu.HideColumn=Hide column
 TableView.menu.NavigateToColumn=Navigate to column
+TableView.menu.ShowAllColumns=Show all columns
+TableView.menu.TableViews=Table views...
 TableView.menu.OptimalSizeWithHeader=Optimal Column size incl. header\tF3
 TableView.menu.OptimalSizeWithoutHeader=Optimal Column size excl. header\tF4
 TableView.menu.PasteFromClipboard=Paste clipboard to table\tCtrl-V
@@ -96,6 +99,7 @@ TableView.WebNewlineHint.Label=* Line breaks aren't shown in 
table cells in the
 TableView.ToolBarWidget.MoveRowsDown.ToolTip=Move selected rows down
 TableView.ToolBarWidget.MoveRowsUp.ToolTip=Move selected rows up
 TableView.ToolBarWidget.NavigateToColumn.ToolTip=Navigate to a column
+TableView.ToolBarWidget.TableViews.ToolTip=Table views
 TableView.tooltip.ExpandValue=Edit this value in a multi-line editor 
(Shift+Enter)
 TableView.tooltip.ViewValue=Show the full value in a multi-line viewer 
(Shift+Enter)
 TableView.tooltip.MultilineEditorKeys=Enter\: save and close - Shift+Enter\: 
new line - Esc\: cancel
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
 
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
new file mode 100644
index 0000000000..a10eb630ee
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.local.LocalAuditManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TableViewColumnViewManagerTest {
+
+  @TempDir Path testFolder;
+
+  @BeforeEach
+  void resetAuditManager() throws HopException {
+    AuditManager.getInstance()
+        .setActiveAuditManager(new 
LocalAuditManager(testFolder.toAbsolutePath().toString()));
+  }
+
+  @Test
+  void saveListLoadAndDelete() {
+    String group = "test-project";
+
+    TableViewColumnViewManager.save(
+        group, new TableViewColumnView("Customer keys", List.of("id", "name", 
"email")));
+    TableViewColumnViewManager.save(
+        group, new TableViewColumnView("Order dates", List.of("order_id", 
"created_at")));
+
+    List<TableViewColumnView> views = TableViewColumnViewManager.list(group);
+    assertEquals(2, views.size());
+    assertEquals("Customer keys", views.get(0).getName());
+    assertEquals("Order dates", views.get(1).getName());
+
+    TableViewColumnView loaded = TableViewColumnViewManager.load(group, 
"Customer keys");
+    assertEquals(List.of("id", "name", "email"), loaded.getColumnNames());
+
+    TableViewColumnViewManager.save(
+        group, new TableViewColumnView("Customer keys", List.of("id", 
"email")));
+    loaded = TableViewColumnViewManager.load(group, "Customer keys");
+    assertEquals(List.of("id", "email"), loaded.getColumnNames());
+
+    TableViewColumnViewManager.delete(group, "Customer keys");
+    assertNull(TableViewColumnViewManager.load(group, "Customer keys"));
+    views = TableViewColumnViewManager.list(group);
+    assertEquals(1, views.size());
+    assertEquals("Order dates", views.get(0).getName());
+  }
+
+  @Test
+  void emptyGroupReturnsNothing() {
+    assertTrue(TableViewColumnViewManager.list("").isEmpty());
+    assertTrue(TableViewColumnViewManager.list(null).isEmpty());
+    assertNull(TableViewColumnViewManager.load("group", null));
+    assertNull(TableViewColumnViewManager.load(null, "name"));
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
new file mode 100644
index 0000000000..150d5451d9
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class TableViewColumnViewsTest {
+
+  @Test
+  void resolvesNamesInViewOrder() {
+    String[] available = {"id", "name", "email", "amount"};
+    List<Integer> indices =
+        TableViewColumnViews.resolveColumnIndices(available, List.of("email", 
"id"));
+
+    assertEquals(List.of(2, 0), indices);
+  }
+
+  @Test
+  void skipsMissingNames() {
+    String[] available = {"id", "name", "email"};
+    List<Integer> indices =
+        TableViewColumnViews.resolveColumnIndices(
+            available, List.of("email", "missing", "id", "also_missing"));
+
+    assertEquals(List.of(2, 0), indices);
+  }
+
+  @Test
+  void usesFirstUnusedMatchForDuplicateAvailableNames() {
+    String[] available = {"id", "name", "id"};
+    List<Integer> indices =
+        TableViewColumnViews.resolveColumnIndices(available, List.of("id", 
"id"));
+
+    assertEquals(List.of(0, 2), indices);
+  }
+
+  @Test
+  void emptyViewOrTableYieldsEmptyResult() {
+    assertTrue(TableViewColumnViews.resolveColumnIndices(new String[0], 
List.of("id")).isEmpty());
+    assertTrue(
+        TableViewColumnViews.resolveColumnIndices(new String[] {"id"}, 
Collections.emptyList())
+            .isEmpty());
+    assertTrue(TableViewColumnViews.resolveColumnIndices(null, 
List.of("id")).isEmpty());
+    assertTrue(TableViewColumnViews.resolveColumnIndices(new String[] {"id"}, 
null).isEmpty());
+  }
+
+  @Test
+  void zeroMatchesIsEmpty() {
+    List<Integer> indices =
+        TableViewColumnViews.resolveColumnIndices(
+            new String[] {"id", "name"}, Arrays.asList("other", null));
+
+    assertTrue(indices.isEmpty());
+  }
+}

Reply via email to