Repository: UIUCLibrary/Speedwagon (language: python)

Issue:
Workflow level settings



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 39f742cf0..f1d07d735 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -15,3 +15,4 @@ exclude_lines =
     pragma: no cover
     if TYPE_CHECKING:
     if typing.TYPE_CHECKING:
+    @overload
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c7623c702..382be020a 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -26,7 +26,9 @@
 
 
 def get_project_metadata():
-    path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../setup.cfg"))
+    path = os.path.abspath(
+        os.path.join(os.path.dirname(__file__), "../../setup.cfg")
+    )
     return read_configuration(path)["metadata"]
 
 
diff --git a/docs/source/exts/workflowssummary.py b/docs/source/exts/workflowssummary.py
index 0d77ebac1..46ff3235c 100644
--- a/docs/source/exts/workflowssummary.py
+++ b/docs/source/exts/workflowssummary.py
@@ -53,7 +53,11 @@ def run(self):
         section = nodes.section(names=[targetname], ids=[targetid])
 
         if "notitle" not in self.options:
-            new_title = nodes.title(workflow.name, text=workflow.name, ids=[targetid])
+            new_title = nodes.title(
+                workflow.name,
+                text=workflow.name,
+                ids=[targetid]
+            )
             section.append(new_title)
 
         if "description" in self.options and workflow.description:
diff --git a/pyproject.toml b/pyproject.toml
index d411b7067..4c2d22374 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,4 +21,17 @@ match="((?!test).*)(^(?!.*(ui)).*)\\.py"
 python_version = "3.11"
 follow_imports_for_stubs=true
 # incremental is problematic because of dependency ruamel.yaml
-incremental=false
+#incremental=false
+
+[tool.ruff]
+select = ["E", "F", "W"]
+exclude = [
+    "windows_build",
+    "tests",
+]
+line-length = 79
+
+[tool.ruff.pydocstyle]
+convention = "google"
+
+#match = "((?!test).*)(^(?!.*(ui)).*)\\.py"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index a6861c052..85214de0d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,3 @@
-aiohttp>=3.7.0
 async-timeout==3.0.1
 attrs>=19.3.0
 HathiValidate>=0.3.8
@@ -9,7 +8,6 @@ importlib-metadata>=1.6.1
 importlib-resources>=1.5.0
 typing-extensions;python_version<"3.11"
 lxml>=4.5.1
-pluggy==0.13.1
 py3exiv2bind>=0.1.10
 pyhathiprep>=0.1.7
 pyqt-distutils>=0.7.3
diff --git a/requirements/requirements_tox.txt b/requirements/requirements_tox.txt
index 3a6d50257..053148f84 100644
--- a/requirements/requirements_tox.txt
+++ b/requirements/requirements_tox.txt
@@ -1,1 +1,1 @@
-tox<4.0
+tox
diff --git a/setup.py b/setup.py
index 0a3c3a508..5ccf91b3c 100644
--- a/setup.py
+++ b/setup.py
@@ -14,7 +14,7 @@
             "uiucprescon.images",
             "pykdu-compress>=0.1.8",
             'importlib_resources;python_version<"3.9"',
-            'importlib-metadata;python_version<"3.8"',
+            'importlib-metadata;python_version<"3.10"',
             'typing-extensions;python_version<"3.11"',
             'lxml',
             "requests",
@@ -26,6 +26,7 @@
             "speedwagon.frontend.cli",
             "speedwagon.frontend.qtwidgets",
             "speedwagon.frontend.qtwidgets.dialog",
+            "speedwagon.frontend.qtwidgets.models",
             "speedwagon.frontend.qtwidgets.ui",
             "speedwagon.tasks",
             "speedwagon.workflows",
@@ -35,7 +36,7 @@
             'pytest',
             'pytest-mock'
         ],
-        python_requires=">=3.6",
+        python_requires=">=3.7",
         extras_require={
           "QT": ["PySide6==6.4.*"]
         },
@@ -49,11 +50,15 @@
         package_data={
             'speedwagon': ["favicon.ico", "logo.png"],
             'speedwagon.frontend.qtwidgets.ui': [
-                "tab_editor.ui",
-                "main_window_shell.ui",
-                "main_window2.ui",
                 "console.ui",
-                'setup_job.ui'
+                "main_window3.ui",
+                "main_window_shell.ui",
+                "plugin_settings.ui",
+                "select_workflow_widget.ui",
+                'setup_job.ui',
+                "tab_editor.ui",
+                "workflow_progress.ui",
+                "workspace.ui",
             ],
         },
     )
diff --git a/speedwagon/__init__.py b/speedwagon/__init__.py
index 560a272a0..8da92dedd 100644
--- a/speedwagon/__init__.py
+++ b/speedwagon/__init__.py
@@ -15,5 +15,5 @@
     "simple_api_run_workflow",
     "worker",
     "JobCancelled",
-    "frontend"
+    "frontend",
 ]
diff --git a/speedwagon/config.py b/speedwagon/config.py
index 4f8278395..48cef6624 100644
--- a/speedwagon/config.py
+++ b/speedwagon/config.py
@@ -1,37 +1,62 @@
 """Load and save user configurations."""
 from __future__ import annotations
+import abc
 import argparse
+import collections.abc
 import configparser
 import contextlib
+try:  # pragma: no cover
+    from importlib import metadata
+except ImportError:  # pragma: no cover
+    import importlib_metadata as metadata  # type: ignore
+import io
 import logging
 import os
 import pathlib
+import platform
 import subprocess
 import sys
 import typing
-from pathlib import Path
-import abc
-import collections.abc
-from typing import Optional, Dict, Type, Set, Iterator, Iterable, Union, List
-from types import TracebackType
-import platform
-import speedwagon
-
+from typing import Optional, Dict, Type, Set, Iterator, Iterable, Union, \
+    List, Callable, NamedTuple
 try:  # pragma: no cover
-    from importlib import metadata
+    from typing import TypedDict
 except ImportError:  # pragma: no cover
-    import importlib_metadata as metadata  # type: ignore
+    from typing_extensions import TypedDict
+from types import TracebackType
+import yaml
+import yaml.emitter
 
-if typing.TYPE_CHECKING:
-    from speedwagon.startup import AbsStarter
+import speedwagon
+from speedwagon.exceptions import TabLoadFailure
 
 __all__ = [
+    "AbsConfig",
+    "AbsConfigSettings",
+    "AbsTabsConfigDataManagement",
     "ConfigManager",
+    "CustomTabsYamlConfig",
     "generate_default",
     "get_platform_settings",
-    "AbsConfig"
+    "SettingsData",
+    "SettingsDataType",
 ]
 
+SettingsDataType = typing.Union[str, bool, int, None]
+SettingsData = Dict[str, SettingsDataType]
+PluginDataType = Dict[str, Dict[str, bool]]
+FullSettingsData = Dict[str, SettingsData]
+
+
+class WorkflowSettingsNameValuePair(TypedDict):
+    name: str
+    value: SettingsDataType
+
+
+StructuredWorkflowSettings = Dict[str, List[WorkflowSettingsNameValuePair]]
+
+WORKFLOWS_SETTINGS_YML_FILE_NAME = "workflows_settings.yml"
+
 
 class AbsConfig(collections.abc.Mapping):
     """Abstract class for defining where speedwagon should find data files."""
@@ -39,7 +64,7 @@ class AbsConfig(collections.abc.Mapping):
     def __init__(self) -> None:
         """Populate the base structure of a config class."""
         super().__init__()
-        self._data: Dict[str,  Union[str, bool]] = {}
+        self._data: SettingsData = {}
 
     @abc.abstractmethod
     def get_user_data_directory(self) -> str:
@@ -61,36 +86,28 @@ def __contains__(self, x: object) -> bool:
         """Check if configuration key is in configuration."""
         if x == "app_data_directory":
             return True
+        return True if x == "user_data_directory" else x in self._data
 
-        if x == "user_data_directory":
-            return True
-
-        return x in self._data
-
-    def __getitem__(self, k: str) -> Union[str, bool]:
+    def __getitem__(self, k: str) -> Union[str, bool, int]:
         """Get configuration value from a key."""
         if k == "user_data_directory":
             return self.get_user_data_directory()
-
         if k == "app_data_directory":
             return self.get_app_data_directory()
-
         return self._data[k]
 
 
 class NixConfig(AbsConfig):
 
     def get_user_data_directory(self) -> str:
-        data_dir = os.path.join(self._get_app_dir(), "data")
-        return data_dir
+        return os.path.join(self._get_app_dir(), "data")
 
     def get_app_data_directory(self) -> str:
-        data_dir = self._get_app_dir()
-        return data_dir
+        return self._get_app_dir()
 
     @staticmethod
     def _get_app_dir() -> str:
-        return os.path.join(str(Path.home()), ".config", "Speedwagon")
+        return os.path.join(str(pathlib.Path.home()), ".config", "Speedwagon")
 
 
 class WindowsConfig(AbsConfig):
@@ -105,7 +122,7 @@ class WindowsConfig(AbsConfig):
     """
 
     def get_user_data_directory(self) -> str:
-        return os.path.join(str(Path.home()), "Speedwagon", "data")
+        return os.path.join(str(pathlib.Path.home()), "Speedwagon", "data")
 
     def get_app_data_directory(self) -> str:
         """Get path the app data for the current system."""
@@ -118,9 +135,7 @@ def get_app_data_directory(self) -> str:
 class ConfigManager(contextlib.AbstractContextManager):
     """Manager for configurations."""
 
-    BOOLEAN_SETTINGS = [
-            "debug",
-        ]
+    BOOLEAN_SETTINGS = ["debug"]
 
     def __init__(self, config_file: str):
         """Set up configuration manager."""
@@ -137,52 +152,62 @@ def __exit__(self,
                  exctype: Optional[Type[BaseException]],
                  excinst: Optional[BaseException],
                  exctb: Optional[TracebackType]) -> Optional[bool]:
+        """Clean up."""
         return None
 
     @property
-    def global_settings(self) -> Dict[str, Union[str, bool]]:
+    def global_settings(self) -> SettingsData:
         """Global settings."""
         if self.cfg_parser is None:
             return {}
-
-        global_settings: Dict[str, Union[str, bool]] = {}
+        global_settings: SettingsData = {}
         try:
             global_section = self.cfg_parser["GLOBAL"]
             for setting in ConfigManager.BOOLEAN_SETTINGS:
                 if setting in global_section:
                     global_settings[setting] = \
                         global_section.getboolean(setting)
-
             for key, value in global_section.items():
                 if key not in ConfigManager.BOOLEAN_SETTINGS:
                     global_settings[key] = value
-
         except KeyError:
             print("Unable to load global settings.", file=sys.stderr)
         return global_settings
 
+    @property
+    def plugins(self) -> Dict[str, Dict[str, bool]]:
+        """Get plugin information from the config file."""
+        if self.cfg_parser is None:
+            return {}
+        plugins = {}
+
+        plugin_prefix = 'PLUGINS.'
+        for section in self.cfg_parser.sections():
+            if not section.startswith(plugin_prefix):
+                continue
+            plugins[section.replace(plugin_prefix, '')] = {
+                entry: self.cfg_parser[section].getboolean(entry)
+                for entry in self.cfg_parser[section].keys()
+            }
+        return plugins
+
 
 def generate_default(config_file: str) -> None:
     """Generate config file with default settings."""
     base_directory = os.path.dirname(config_file)
     if base_directory and not os.path.exists(base_directory):
         os.makedirs(base_directory)
-
     platform_settings = get_platform_settings()
-    data_dir = platform_settings.get("user_data_directory")
+    data_dir: Optional[str] = platform_settings.get("user_data_directory")
     if data_dir is None:
         raise FileNotFoundError("Unable to locate user data directory")
-
-    tessdata = os.path.join(data_dir, "tessdata")
-
     config = configparser.ConfigParser(allow_no_value=True)
     config.add_section("GLOBAL")
     config['GLOBAL'] = {
-        "tessdata": tessdata,
+        "tessdata": os.path.join(data_dir, "tessdata"),
         "starting-tab": "Tools",
         "debug": "False"
     }
-
     with open(config_file, "w", encoding="utf-8") as file:
         config.write(file)
     ensure_keys(config_file, speedwagon.job.all_required_workflow_keys())
@@ -226,12 +251,7 @@ def find_missing_global_entries(
     config_data = configparser.ConfigParser()
     config_data.read(config_file)
     global_settings = config_data['GLOBAL']
-    missing = set()
-    for k in expected_keys:
-        if k not in global_settings:
-            missing.add(k)
-
-    return missing if len(missing) > 0 else None
+    return {k for k in expected_keys if k not in global_settings} or None
 
 
 def ensure_keys(config_file: str, keys: Iterable[str]) -> Optional[Set[str]]:
@@ -252,68 +272,83 @@ def ensure_keys(config_file: str, keys: Iterable[str]) -> Optional[Set[str]]:
     config_data.read(config_file)
     global_settings = config_data['GLOBAL']
     added = set()
-
     for k in keys:
         if k not in global_settings:
             config_data['GLOBAL'][k] = ""
             added.add(k)
-
     with open(config_file, "w", encoding="utf-8") as file_pointer:
         config_data.write(file_pointer)
-
-    return added if len(added) > 0 else None
+    return added or None
 
 
-class AbsSetting(metaclass=abc.ABCMeta):
-
-    @staticmethod
-    @property
-    @abc.abstractmethod
-    def friendly_name():
-        return NotImplementedError
+class AbsSetting(metaclass=abc.ABCMeta):  # pylint: disable=R0903
 
     def update(
             self,
-            settings: Optional[Dict[str, Union[str, bool]]] = None
-    ) -> Dict["str", Union[str, bool]]:
-        if settings is None:
-            return {}
-        return settings
+            settings: Optional[FullSettingsData] = None
+    ) -> FullSettingsData:
+        return {} if settings is None else settings
 
 
-class DefaultsSetter(AbsSetting):
-    friendly_name = "Setting defaults"
-
+class DefaultsSetter(AbsSetting):  # pylint: disable=R0903
     def update(
             self,
-            settings: Optional[Dict[str, Union[str, bool]]] = None
-    ) -> Dict["str", Union[str, bool]]:
+            settings: Optional[FullSettingsData] = None
+    ) -> FullSettingsData:
         new_settings = super().update(settings)
-        new_settings["debug"] = False
+        if "GLOBAL" not in new_settings:
+            new_settings["GLOBAL"] = {}
+        global_settings = new_settings["GLOBAL"]
+        global_settings["debug"] = False
         return new_settings
 
 
-class ConfigFileSetter(AbsSetting):
+class ConfigFileSetter(AbsSetting):  # pylint: disable=R0903
     friendly_name = "Config file settings"
 
     def __init__(self, config_file: str):
         """Create a new config file setter."""
         self.config_file = config_file
+        self.boolean_settings: List[str] = ["debug"]
+        self.int_settings: List[str] = []
+
+    @staticmethod
+    def read_config_data(config_file: str) -> str:
+        with open(config_file, encoding="utf-8") as file_handle:
+            return file_handle.read()
 
     def update(
             self,
-            settings: Optional[Dict[str, Union[str, bool]]] = None
-    ) -> Dict["str", Union[str, bool]]:
+            settings: Optional[FullSettingsData] = None
+    ) -> FullSettingsData:
         """Update setting configuration."""
         new_settings = super().update(settings)
-        with speedwagon.config.ConfigManager(self.config_file) as cfg:
-            new_settings.update(cfg.global_settings.items())
+        cfg_parser = configparser.ConfigParser()
+        cfg_parser.read_string(self.read_config_data(self.config_file))
+        settings_data = {
+            section_name: self.process_section(cfg_parser[section_name])
+            for section_name in cfg_parser.sections()
+        }
+        new_settings.update(settings_data)
         return new_settings
 
+    def process_section(
+            self,
+            section: configparser.SectionProxy
+    ) -> SettingsData:
+        processed_section: SettingsData = {}
+
+        for key, value in section.items():
+            if key in self.boolean_settings:
+                processed_section[key] = section.getboolean(key)
+            elif key in self.int_settings:
+                processed_section[key] = section.getint(key)
+            else:
+                processed_section[key] = value
+        return processed_section
 
-class CliArgsSetter(AbsSetting):
 
-    friendly_name = "Command line arguments setting"
+class CliArgsSetter(AbsSetting):
 
     def __init__(self, args: Optional[typing.List[str]] = None) -> None:
         super().__init__()
@@ -321,16 +356,21 @@ def __init__(self, args: Optional[typing.List[str]] = None) -> None:
 
     def update(
             self,
-            settings: Optional[Dict[str, Union[str, bool]]] = None
-    ) -> Dict["str", Union[str, bool]]:
+            settings: Optional[FullSettingsData] = None
+    ) -> FullSettingsData:
         new_settings = super().update(settings)
-
+        if "GLOBAL" not in new_settings:
+            new_settings["GLOBAL"] = {}
+        global_settings = new_settings['GLOBAL']
         args = self._parse_args(self.args)
-        if args.start_tab is not None:
-            new_settings["starting-tab"] = args.start_tab
 
-        if args.debug is True:
-            new_settings["debug"] = args.debug
+        starting_tab: Optional[str] = args.start_tab
+        if starting_tab is not None:
+            global_settings["starting-tab"] = starting_tab
+
+        debug: bool = args.debug
+        if debug:
+            global_settings["debug"] = debug
 
         return new_settings
 
@@ -381,66 +421,9 @@ def _parse_args(args: typing.List[str]) -> argparse.Namespace:
         return parser.parse_args(args)
 
 
-class ConfigLoader:
-
-    @staticmethod
-    def read_settings_file(settings_file: str) -> Dict[str, Union[str, bool]]:
-        with speedwagon.config.ConfigManager(settings_file) as config:
-            return config.global_settings
-
-    def __init__(self, config_file: str) -> None:
-        super().__init__()
-        self.config_file = config_file
-        self.resolution_strategy_order: Optional[List[AbsSetting]] = None
-        self.platform_settings = get_platform_settings()
-        self.logger = logging.getLogger(__package__)
-        self.startup_settings: Dict[str, Union[str, bool]] = {}
-
-    @staticmethod
-    def _resolve(
-            resolution_strategy_order: Iterable[AbsSetting],
-            config_file: str,
-            starting_settings: Dict[str, Union[str, bool]],
-            logger: logging.Logger
-    ) -> Dict[str, Union[str, bool]]:
-
-        settings = starting_settings.copy()
-        for settings_strategy in resolution_strategy_order:
-
-            logger.debug("Loading settings from %s",
-                         settings_strategy.friendly_name)
-
-            try:
-                settings = settings_strategy.update(settings)
-            except ValueError as error:
-                if isinstance(settings_strategy, ConfigFileSetter):
-                    logger.warning(
-                        "%s contains an invalid setting. Details: %s",
-                        config_file,
-                        error
-                    )
-
-                else:
-                    logger.warning("%s is an invalid setting", error)
-        return settings
-
-    def get_settings(self) -> Dict[str, Union[str, bool]]:
-        self.read_settings_file(self.config_file)
-        if self.resolution_strategy_order is None:
-            resolution_order = [
-                speedwagon.config.DefaultsSetter(),
-                ConfigFileSetter(self.config_file),
-                speedwagon.config.CliArgsSetter(),
-            ]
-        else:
-            resolution_order = self.resolution_strategy_order
-
-        return self._resolve(
-            resolution_order,
-            config_file=self.config_file,
-            logger=self.logger,
-            starting_settings=self.startup_settings
-        )
+def read_settings_file_plugins(settings_file: str) -> PluginDataType:
+    with speedwagon.config.ConfigManager(settings_file) as config:
+        return config.plugins
 
 
 class AbsEnsureConfigFile(abc.ABC):
@@ -471,14 +454,14 @@ class CreateBasicMissingConfigFile(AbsEnsureConfigFile):
 
     def __init__(
             self,
-            app: AbsStarter,
             logger: Optional[logging.Logger] = None
     ) -> None:
         super().__init__(logger)
-        self.app = app
+        self.config_strategy = StandardConfigFileLocator()
 
     def ensure_config_file(self, file_path: Optional[str] = None) -> None:
-        file_path = file_path or self.app.config_file
+
+        file_path = file_path or self.config_strategy.get_config_file()
         if not os.path.exists(file_path):
             generate_default(file_path)
 
@@ -487,7 +470,8 @@ def ensure_config_file(self, file_path: Optional[str] = None) -> None:
             self.logger.debug("Found existing config file %s", file_path)
 
     def ensure_tabs_file(self, file_path: Optional[str] = None) -> None:
-        file_path = file_path or self.app.tabs_file
+
+        file_path = file_path or self.config_strategy.get_tabs_file()
         if not os.path.exists(file_path):
             pathlib.Path(file_path).touch()
 
@@ -499,7 +483,7 @@ def ensure_tabs_file(self, file_path: Optional[str] = None) -> None:
                 "Found existing tabs file %s", file_path)
 
     def ensure_user_data_dir(self, directory: Optional[str] = None) -> None:
-        directory = directory or self.app.user_data_dir
+        directory = directory or self.config_strategy.get_user_data_dir()
         if directory and not os.path.exists(directory):
             os.makedirs(directory)
             self.logger.debug("Created directory %s", directory)
@@ -511,7 +495,7 @@ def ensure_user_data_dir(self, directory: Optional[str] = None) -> None:
             )
 
     def ensure_app_data_dir(self, directory: Optional[str] = None) -> None:
-        directory = directory or self.app.app_data_dir
+        directory = directory or self.config_strategy.get_app_data_dir()
         if directory is not None and \
                 not os.path.exists(directory):
 
@@ -526,14 +510,12 @@ def ensure_app_data_dir(self, directory: Optional[str] = None) -> None:
 
 
 def ensure_settings_files(
-        starter: AbsStarter,
         logger: Optional[logging.Logger] = None,
         strategy: Optional[AbsEnsureConfigFile] = None
 ) -> None:
 
     logger = logger or logging.getLogger(__package__)
     strategy = strategy or CreateBasicMissingConfigFile(
-        app=starter,
         logger=logger
     )
     strategy.ensure_config_file()
@@ -554,6 +536,7 @@ def system_open_directory(self, settings_directory: str) -> None:
 
         Args:
             settings_directory: Path to the directory
+
         """
 
     def open(self) -> None:
@@ -569,7 +552,7 @@ class WindowsOpenSettings(AbsOpenSettings):
 
     def system_open_directory(self, settings_directory: str) -> None:
         # pylint: disable=no-member
-        os.startfile(settings_directory)  # type: ignore
+        os.startfile(settings_directory)  # type: ignore[attr-defined]
 
 
 class OpenSettingsDirectory:
@@ -582,3 +565,571 @@ def system_open_directory(self, settings_directory: str) -> None:
 
     def open(self) -> None:
         self.strategy.open()
+
+
+def get_whitelisted_plugins() -> Set[typing.Tuple[str, str]]:
+    config_strategy = speedwagon.config.StandardConfigFileLocator()
+    plugin_settings = \
+        read_settings_file_plugins(config_strategy.get_config_file())
+
+    white_listed_plugins = set()
+    for module, entry_points in plugin_settings.items():
+        for entry_point in entry_points:
+            white_listed_plugins.add((module, entry_point))
+    return white_listed_plugins
+
+
+class AbsSettingLocator(abc.ABC):
+    @abc.abstractmethod
+    def get_user_data_dir(self) -> str:
+        """Get user data directory."""
+
+    @abc.abstractmethod
+    def get_app_data_dir(self) -> str:
+        """Get app data directory."""
+
+    @abc.abstractmethod
+    def get_config_file(self) -> str:
+        """Get config file used."""
+
+    @abc.abstractmethod
+    def get_tabs_file(self) -> str:
+        """Get tabs settings file used."""
+
+
+class StandardConfigFileLocator(AbsSettingLocator):
+    def __init__(self) -> None:
+        self._platform_settings = get_platform_settings()
+
+    def get_user_data_dir(self) -> str:
+        return typing.cast(
+            str,
+            self._platform_settings.get("user_data_directory")
+        )
+
+    def get_config_file(self) -> str:
+        return os.path.join(
+            self._platform_settings.get_app_data_directory(),
+            speedwagon.startup.CONFIG_INI_FILE_NAME
+        )
+
+    def get_app_data_dir(self) -> str:
+        return typing.cast(str, self._platform_settings["app_data_directory"])
+
+    def get_tabs_file(self) -> str:
+        return os.path.join(
+            self._platform_settings.get_app_data_directory(),
+            speedwagon.startup.TABS_YML_FILE_NAME
+        )
+
+
+class AbsConfigSettings(abc.ABC):  # pylint: disable=R0903
+    """Abstract base class for getting settings."""
+
+    @abc.abstractmethod
+    def settings(self) -> FullSettingsData:
+        """Get the current app settings."""
+
+
+class StandardConfig(AbsConfigSettings):
+    def __init__(self) -> None:
+        super().__init__()
+        self.config_loader_strategy: Optional[AbsConfigLoader] = None
+
+    def settings(self) -> FullSettingsData:
+        return self.resolve_settings()
+
+    def resolve_settings(self) -> FullSettingsData:
+        if self.config_loader_strategy is not None:
+            return self.config_loader_strategy.get_settings()
+        loader = MixedConfigLoader()
+        loader.resolution_strategy_order = [
+            DefaultsSetter(),
+            ConfigFileSetter(StandardConfigFileLocator().get_config_file()),
+            CliArgsSetter(),
+        ]
+        return loader.get_settings()
+
+
+class AbsTabsConfigDataManagement(abc.ABC):
+    """Abstract base model for managing saving and loading serialized data."""
+
+    @abc.abstractmethod
+    def data(self) -> List[CustomTabData]:
+        """Get the data for custom tabs."""
+
+    @abc.abstractmethod
+    def save(self, tabs: List[CustomTabData]):
+        """Get the data for custom tabs."""
+
+
+class CustomTabData(NamedTuple):
+    tab_name: str
+    workflow_names: List[str]
+
+
+class AbsTabsYamlFileReader(abc.ABC):
+    @staticmethod
+    @abc.abstractmethod
+    def read_file(yaml_file: str) -> str:
+        """Read file and return a string."""
+
+    @abc.abstractmethod
+    def decode_tab_settings_yml_data(self, data: str) -> Dict[str,  List[str]]:
+        """Decode data."""
+
+
+class TabsYamlFileReader(AbsTabsYamlFileReader):
+    @staticmethod
+    def read_file(yaml_file: str) -> str:
+        with open(yaml_file, encoding="utf-8") as file_handler:
+            return file_handler.read()
+
+    def decode_tab_settings_yml_data(self, data: str) -> Dict[str,  List[str]]:
+        tabs_config_data = yaml.load(data, Loader=yaml.SafeLoader)
+        if not isinstance(tabs_config_data, dict):
+            raise speedwagon.exceptions.FileFormatError("Failed to parse file")
+        return tabs_config_data
+
+
+class CustomTabsYamlConfig(AbsTabsConfigDataManagement):
+    """YAML config file manager."""
+
+    def __init__(self, yaml_file: str) -> None:
+        """Create a new yaml config object.
+
+        Args:
+            yaml_file: path to a yaml file to use to read or save to.
+
+        """
+        self.yaml_file = yaml_file
+        self.file_reader_strategy: AbsTabsYamlFileReader = TabsYamlFileReader()
+        self.file_writer_strategy: AbsTabWriter = TabsYamlWriter()
+        self.data_reader: Optional[Callable[[], str]] = None
+
+    def decode_data(self, data: str) -> Dict[str,  List[str]]:
+        """Decode a YAML string to a dictionary."""
+        return self.file_reader_strategy.decode_tab_settings_yml_data(data)
+
+    def data(self) -> List[CustomTabData]:
+        """Get Yaml file data."""
+        try:
+            if self.data_reader is not None:
+                data = self.data_reader()
+            else:
+                data = self.file_reader_strategy.read_file(self.yaml_file)
+            yml_data = \
+                self.file_reader_strategy.decode_tab_settings_yml_data(data)
+        except yaml.YAMLError as error:
+            raise TabLoadFailure(
+                f"{self.yaml_file} file failed to load."
+            ) from error
+        except FileNotFoundError as error:
+            raise TabLoadFailure(
+                f"Custom tabs file {self.yaml_file} not found"
+            ) from error
+        except (TypeError, speedwagon.exceptions.FileFormatError) as error:
+            raise TabLoadFailure() from error
+        return [
+            CustomTabData(tab_name, workflow_names)
+            for tab_name, workflow_names in yml_data.items()
+        ]
+
+    def save(self, tabs: List[CustomTabData]):
+        """Write tabs to a yaml file."""
+        self.file_writer_strategy.save(self.yaml_file, tabs)
+
+
+class AbsTabWriter(abc.ABC):  # pylint: disable=R0903
+    def save(self, file_name: str, tabs: List[CustomTabData]) -> None:
+        """Save tabs data to a file format."""
+
+
+class TabsYamlWriter(AbsTabWriter):
+    def save(self, file_name: str, tabs: List[CustomTabData]) -> None:
+        self.write_data(file_name, self.serialize(tabs))
+
+    @staticmethod
+    def write_data(file_name: str, data: str) -> None:
+        with open(file_name, "w", encoding="utf-8") as file_handle:
+            file_handle.write(data)
+
+    @staticmethod
+    def serialize(tabs: Iterable[CustomTabData]) -> str:
+        tabs_data = {
+            tab_name: list(tab_workflows) for tab_name, tab_workflows in tabs
+        }
+        with io.StringIO() as file_handle:
+            yaml.dump(tabs_data, file_handle, default_flow_style=False)
+            value = file_handle.getvalue()
+        return value
+
+
+class AbsGlobalConfigDataManagement(abc.ABC):
+    @abc.abstractmethod
+    def save(self, data: FullSettingsData) -> None:
+        """Save data."""
+
+    @abc.abstractmethod
+    def data(self) -> FullSettingsData:
+        """Get data."""
+
+
+class AbsConfigSaver(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def save(self, file_name: str, data: FullSettingsData):
+        """Save data to a file."""
+
+
+class IniConfigManager(AbsGlobalConfigDataManagement):
+
+    def __init__(self, ini_file: Optional[str] = None) -> None:
+        self.config_file: Optional[str] = ini_file
+        self.loader: Optional[AbsConfigLoader] = None
+        self.saver: Optional[AbsConfigSaver] = None
+        self.config_resolution_order: Optional[List[AbsSetting]] = None
+
+    def get_resolution_order(self) -> List[AbsSetting]:
+        if self.config_resolution_order is not None:
+            return self.config_resolution_order
+        config_file = \
+            self.config_file or \
+            StandardConfigFileLocator().get_config_file()
+        resolution_order: List[AbsSetting] = [DefaultsSetter()]
+        if self.config_file:
+            resolution_order.append(ConfigFileSetter(config_file))
+        resolution_order.append(speedwagon.config.CliArgsSetter())
+        return resolution_order
+
+    def loader_strategy(self) -> AbsConfigLoader:
+        if self.loader:
+            return self.loader
+        strategy = MixedConfigLoader()
+        strategy.resolution_strategy_order = self.get_resolution_order()
+        return strategy
+
+    def save_strategy(self) -> AbsConfigSaver:
+        return self.saver or IniConfigSaver()
+
+    def data(self) -> FullSettingsData:
+        return self.loader_strategy().get_settings()
+
+    def save(self, data: FullSettingsData) -> None:
+        if self.config_file is None:
+            return
+        self.save_strategy().save(self.config_file, data)
+
+
+class IniConfigSaver(AbsConfigSaver):
+
+    def save(self, file_name: str, data: FullSettingsData):
+        self.write_data_to_file(
+            file_name,
+            serialized_data=self.serialize(data)
+        )
+
+    def serialize(self, data: FullSettingsData) -> str:
+        config_data = configparser.ConfigParser()
+        for heading, item_data in data.items():
+            config_data[heading] = {
+                key: str(value)
+                for key, value in item_data.items()
+            }
+        with io.StringIO() as string_writer:
+            config_data.write(string_writer)
+            return string_writer.getvalue()
+
+    def write_data_to_file(self, file_name: str, serialized_data: str) -> None:
+        with open(file_name, "w", encoding="utf-8") as file_handler:
+            file_handler.write(serialized_data)
+
+
+class AbsConfigLoader(abc.ABC):  # pylint: disable=R0903
+
+    @abc.abstractmethod
+    def get_settings(self) -> FullSettingsData:
+        """Get the settings data."""
+
+
+class MixedConfigLoader(AbsConfigLoader):  # pylint: disable=R0903
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.resolution_strategy_order: List[AbsSetting] = [
+            speedwagon.config.DefaultsSetter()
+        ]
+        self.platform_settings = get_platform_settings()
+
+    @staticmethod
+    def _resolve(
+            resolution_strategy_order: Iterable[AbsSetting]
+    ) -> FullSettingsData:
+        settings: FullSettingsData = {}
+        for settings_strategy in resolution_strategy_order:
+            settings = settings_strategy.update(settings)
+        return settings
+
+    def get_settings(self) -> FullSettingsData:
+        return self._resolve(self.resolution_strategy_order)
+
+
+class AbsWorkflowSettingsManager(abc.ABC):
+
+    @abc.abstractmethod
+    def get_workflow_settings(
+            self,
+            workflow: speedwagon.Workflow
+    ) -> SettingsData:
+        """Get settings for a workflow configured through the application."""
+
+    @abc.abstractmethod
+    def save_workflow_settings(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> None:
+        """Save workflow settings."""
+
+
+class IndentingEmitter(yaml.emitter.Emitter):  # pylint: disable=R0903
+    def increase_indent(self, flow=False, indentless=False):
+        """Ensure that lists items are always indented."""
+        return super().increase_indent(flow=False, indentless=False)
+
+
+class IndentedYAMLDumper(yaml.Dumper):  # pylint: disable=R0903
+
+    def increase_indent(self, flow=False, indentless=False):
+        return super().increase_indent(flow, False)
+
+
+class AbsWorkflowSettingsExporter(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def save(
+            self,
+            workflow:
+            speedwagon.Workflow,
+            settings: SettingsData
+    ) -> None:
+        """Save settings."""
+
+
+class AbsYamlConfigFileManager(abc.ABC):  # pylint: disable=R0903
+    def __init__(self, yaml_file: str) -> None:
+        super().__init__()
+        self.yaml_file = yaml_file
+
+
+class AbsSettingsSerializer(abc.ABC):  # pylint: disable=R0903
+
+    @abc.abstractmethod
+    def serialize(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> str:
+        """Serialize workflow settings."""
+
+
+class SettingsYamlSerializer(AbsSettingsSerializer):
+
+    def __init__(
+            self,
+            existing_data: Optional[StructuredWorkflowSettings] = None
+    ) -> None:
+        super().__init__()
+        self.starting_data = existing_data or {}
+
+    @staticmethod
+    def serialize_structure_to_yaml(data: StructuredWorkflowSettings) -> str:
+        with io.StringIO() as file_handle:
+            yaml.dump(
+                dict(sorted(data.items())),
+                file_handle,
+                Dumper=IndentedYAMLDumper
+            )
+            return file_handle.getvalue()
+
+    @staticmethod
+    def structure_workflow_data(
+            settings: SettingsData
+    ) -> List[WorkflowSettingsNameValuePair]:
+        return [
+            {"name": key, "value": value}
+            for key, value in settings.items()
+        ]
+
+    def serialize(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> str:
+        data: StructuredWorkflowSettings = self.starting_data.copy()
+        workflow_name = workflow.name if workflow.name is not None else ''
+        if workflow_name in data:
+            del data[workflow_name]
+        data[workflow_name] = self.structure_workflow_data(settings)
+        return self.serialize_structure_to_yaml(data)
+
+
+class WorkflowSettingsYamlExporter(
+    AbsYamlConfigFileManager,
+    AbsWorkflowSettingsExporter
+):
+    def __init__(
+            self,
+            yaml_file: str,
+            yaml_serialization_strategy: Optional[AbsSettingsSerializer] = None
+    ) -> None:
+        super().__init__(yaml_file)
+        self.yaml_serialization_strategy = yaml_serialization_strategy
+
+    @staticmethod
+    def write_data_to_file(data: str, file_name: str) -> None:
+        with open(file_name, "w", encoding="utf-8") as file_handle:
+            file_handle.write(data)
+
+    def get_existing_data(self):
+        if os.path.exists(self.yaml_file):
+            with open(self.yaml_file, "r", encoding="utf-8") as handle:
+                return yaml.load(handle, Loader=yaml.SafeLoader)
+        return {}
+
+    def _get_serializer(self) -> AbsSettingsSerializer:
+        if self.yaml_serialization_strategy is not None:
+            return self.yaml_serialization_strategy
+        return SettingsYamlSerializer(self.get_existing_data())
+
+    def serialize_settings_data(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> str:
+        return self._get_serializer().serialize(workflow, settings)
+
+    def save(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> None:
+        self.write_data_to_file(
+            data=self.serialize_settings_data(workflow, settings),
+            file_name=self.yaml_file
+        )
+
+
+class AbsWorkflowSettingsResolver(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def get_response(self, workflow: speedwagon.Workflow) -> SettingsData:
+        """Get settings data from workflow."""
+
+
+class WorkflowSettingsYAMLResolver(
+    AbsYamlConfigFileManager,
+    AbsWorkflowSettingsResolver
+):
+
+    @staticmethod
+    def read_file(file_name: str) -> str:
+        with open(file_name, "r", encoding="utf-8") as file_handle:
+            return file_handle.read()
+
+    def get_config_data(self):
+        config_file = self.yaml_file
+        return (
+            yaml.load(self.read_file(config_file), Loader=yaml.SafeLoader)
+            if os.path.exists(config_file)
+            else {}
+        )
+
+    def get_response(self, workflow: speedwagon.Workflow) -> SettingsData:
+        config_data = self.get_config_data()
+        if workflow.name not in config_data:
+            return {}
+        valid_options = [
+            i.setting_name if i.setting_name is not None else i.label
+            for i in workflow.workflow_options()
+        ]
+        return {
+            item['name']: item['value']
+            for item in config_data[workflow.name]
+            if item['name'] in valid_options
+        }
+
+
+class WorkflowSettingsManager(AbsWorkflowSettingsManager):
+    def __init__(
+            self,
+            getter_strategy: Optional[AbsWorkflowSettingsResolver] = None,
+            setter_strategy: Optional[AbsWorkflowSettingsExporter] = None
+    ) -> None:
+        super().__init__()
+        self.settings_getter_strategy: AbsWorkflowSettingsResolver = \
+            getter_strategy or \
+            WorkflowSettingsYAMLResolver(self._get_yaml_file())
+        self.settings_saver_strategy: AbsWorkflowSettingsExporter = \
+            setter_strategy or \
+            WorkflowSettingsYamlExporter(self._get_yaml_file())
+
+    @staticmethod
+    def _get_yaml_file() -> str:
+        return os.path.join(
+            StandardConfigFileLocator().get_app_data_dir(),
+            WORKFLOWS_SETTINGS_YML_FILE_NAME
+        )
+
+    def get_workflow_settings(
+            self,
+            workflow: speedwagon.Workflow
+    ) -> SettingsData:
+        return self.settings_getter_strategy.get_response(workflow)
+
+    def save_workflow_settings(
+            self,
+            workflow: speedwagon.Workflow,
+            settings: SettingsData
+    ) -> None:
+        self.settings_saver_strategy.save(workflow, settings)
+
+
+class AbsWorkflowBackend(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def get(self, key: str) -> Optional[SettingsDataType]:
+        """Get data for some key."""
+
+
+class YAMLWorkflowConfigBackend(AbsWorkflowBackend):
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.yaml_file: Optional[str] = None
+        self.workflow: Optional[speedwagon.Workflow] = None
+        self.settings_resolver: Optional[AbsWorkflowSettingsResolver] = None
+
+    def get_yaml_strategy(self):
+        if self.settings_resolver is not None:
+            return self.settings_resolver
+        if self.yaml_file is None:
+            raise AttributeError("yaml_file not set")
+        return speedwagon.config.WorkflowSettingsYAMLResolver(self.yaml_file)
+
+    def get(self, key: str) -> Optional[SettingsDataType]:
+        if any(
+                [
+                    self.yaml_file is None,
+                    self.workflow is None
+                ]
+        ):
+            return None
+        return self.get_yaml_strategy().get_response(self.workflow).get(key)
+
+
+def get_config_backend():
+    config_backend = YAMLWorkflowConfigBackend()
+    config_strategy = StandardConfigFileLocator()
+    backend_yaml = os.path.join(
+        config_strategy.get_app_data_dir(),
+        WORKFLOWS_SETTINGS_YML_FILE_NAME
+    )
+    config_backend.yaml_file = backend_yaml
+    return config_backend
diff --git a/speedwagon/exceptions.py b/speedwagon/exceptions.py
index 4d515c667..5ff60c5da 100644
--- a/speedwagon/exceptions.py
+++ b/speedwagon/exceptions.py
@@ -34,3 +34,28 @@ def __init__(self, *args: object, expected: bool = False) -> None:
         """
         super().__init__(*args)
         self.expected = expected
+
+
+class PluginImportError(Exception):
+    """Plugin(s) import failed."""
+
+
+class InvalidPlugin(Exception):
+    """Invalid plugin has been attempted to be instantiated."""
+
+    def __init__(self, *args: object, entry_point) -> None:
+        """Create a new InvalidPlugin exception."""
+        super().__init__(*args)
+        self.entry_point = entry_point
+
+
+class FileFormatError(SpeedwagonException):
+    """Exception is thrown when Something wrong with the contents of a file."""
+
+
+class TabLoadFailure(SpeedwagonException):
+    """Exception is thrown when a tab fails to load."""
+
+
+class WorkflowLoadFailure(SpeedwagonException):
+    """Exception is thrown when a Workflow fails to load."""
diff --git a/speedwagon/frontend/qtwidgets/dialog/dialogs.py b/speedwagon/frontend/qtwidgets/dialog/dialogs.py
index f7f53d37f..22150b7c8 100644
--- a/speedwagon/frontend/qtwidgets/dialog/dialogs.py
+++ b/speedwagon/frontend/qtwidgets/dialog/dialogs.py
@@ -5,6 +5,7 @@
 import sys
 import typing
 import warnings
+import time
 from typing import Optional, Sequence
 
 from PySide6 import QtWidgets, QtGui, QtCore  # type: ignore
@@ -22,6 +23,7 @@
     from importlib_resources import as_file  # type: ignore
 
 import speedwagon
+from speedwagon.reports import ExceptionReport
 from speedwagon.frontend.qtwidgets import logging_helpers, ui_loader
 import speedwagon.frontend.qtwidgets.ui
 
@@ -399,23 +401,10 @@ def stop(self) -> None:
 
 
 class WorkflowProgressGui(QtWidgets.QDialog):
-    class DialogLogHandler(logging.handlers.BufferingHandler):
-        class LogSignals(QtCore.QObject):
-            message = QtCore.Signal(str)
-
-        def __init__(self, dialog: "WorkflowProgressGui") -> None:
-            super().__init__(capacity=100)
-            self.signals = WorkflowProgress.DialogLogHandler.LogSignals()
-            self._dialog = dialog
-            self.signals.message.connect(
-                self._dialog.write_html_block_to_console
-            )
-
-        def flush(self) -> None:
-            results = [self.format(log).strip() for log in self.buffer]
-            if results:
-                self.signals.message.emit("".join(results))
-            super().flush()
+    button_box: QtWidgets.QDialogButtonBox
+    banner: QtWidgets.QLabel
+    progress_bar: QtWidgets.QProgressBar
+    console: QtWidgets.QTextBrowser
 
     def __init__(
             self,
@@ -445,7 +434,11 @@ def __init__(
         self._parent_logger: typing.Optional[logging.Logger] = None
 
         self._console_data = QtGui.QTextDocument(parent=self)
+
+        # The extra typehint is to fix typehints due to the way
+        # QtGui.QTextCursor is reporting it as Callable[[QWidget], QCursor]
         self.cursor: QtGui.QTextCursor = QtGui.QTextCursor(self._console_data)
+
         self.cursor.movePosition(self.cursor.MoveOperation.End)
 
     def write_html_block_to_console(self, html: str) -> None:
@@ -459,7 +452,10 @@ def flush(self) -> None:
 
     def attach_logger(self, logger: logging.Logger) -> None:
         self._parent_logger = logger
-        self._log_handler = WorkflowProgressGui.DialogLogHandler(self)
+        self._log_handler = logging_helpers.QtSignalLogHandler(self)
+        self._log_handler.signals.messageSent.connect(
+            self.write_html_block_to_console
+        )
         formatter = logging_helpers.ConsoleFormatter()
         self._log_handler.setFormatter(formatter)
         self._parent_logger.addHandler(self._log_handler)
@@ -514,19 +510,8 @@ def __init__(
         self.state: AbsWorkflowProgressState = WorkflowProgressStateIdle(self)
         # =====================================================================
 
-        # Seems to be causing the segfault
-        # self.finished.connect(self.clean_local_console)
-
         self.finished.connect(self.remove_log_handles)  # type: ignore
 
-        self._refresh_timer = QtCore.QTimer(self)
-        self._refresh_timer.timeout.connect(self.flush)  # type: ignore
-        self.finished.connect(self.stop_timer)  # type: ignore
-        self._refresh_timer.start(100)
-
-    def stop_timer(self) -> None:
-        self._refresh_timer.stop()
-
     def clean_local_console(self) -> None:
         # CRITICAL: Running self.console.clear() seems to cause A SEGFAULT when
         # shutting down!!!
@@ -598,3 +583,87 @@ def write_to_console(self, text: str, level: int = logging.INFO) -> None:
 
         cursor.insertText("\n")
         cursor.endEditBlock()
+
+
+class AbsSaveReport(abc.ABC):  # pylint: disable=R0903
+    qt_parent: QtWidgets.QWidget
+    default_name: str
+
+    @abc.abstractmethod
+    def save(self, data: str) -> None:
+        """Save data to a file."""
+
+
+class SaveReportDialogBox(AbsSaveReport):
+    def __init__(self) -> None:
+        self.default_name = "report.txt"
+        self.qt_parent: QtWidgets.QWidget = QtWidgets.QWidget()
+
+    def save(self, data: str) -> None:
+        while True:
+            log_file_name: Optional[str]
+            log_file_name, _ = \
+                QtWidgets.QFileDialog.getSaveFileName(
+                    self.qt_parent,
+                    "Export crash data",
+                    self.default_name,
+                    "Text Files (*.txt)")
+            if not log_file_name:
+                return
+            if self.write_data(log_file_name, data) is False:
+                continue
+            print(f"Save crash info to {log_file_name}")
+            break
+
+    def write_data(self, file_name: str, data: str) -> bool:
+        """Write data to a file.
+
+        Returns True on success and False on failure.
+        """
+        try:
+            with open(file_name, "w", encoding="utf-8") as file_handle:
+                file_handle.write(data)
+            return True
+        except OSError as error:
+            message_box = QtWidgets.QMessageBox(self.qt_parent)
+            message_box.setText("Saving data failed")
+            message_box.setDetailedText(str(error))
+            message_box.exec()
+            return False
+
+
+class SpeedwagonExceptionDialog(QtWidgets.QMessageBox):
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        self.setIcon(self.Icon.Critical)
+        self.report_strategy = ExceptionReport()
+        self.save_report_strategy: AbsSaveReport = SaveReportDialogBox()
+
+        self.setText("Speedwagon has hit an exception")
+        self.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Close)
+
+        self.export_button = \
+            self.addButton(
+                "Export Details",
+                QtWidgets.QMessageBox.ButtonRole.ActionRole
+            )
+        self.export_button.clicked.disconnect()
+        self.export_button.clicked.connect(self.export_information)
+
+    def export_information(self) -> None:
+        epoch_in_minutes = int(time.time() / 60)
+        file_name = f"speedwagon_crash_{epoch_in_minutes}.txt"
+        self.save_report_strategy.default_name = file_name
+        self.save_report_strategy.qt_parent = self
+        self.save_report_strategy.save(self.report_strategy.report())
+
+    @property
+    def exception(self) -> Optional[BaseException]:
+        return self.report_strategy.exception
+
+    @exception.setter
+    def exception(self, value: BaseException) -> None:
+        self.report_strategy.exception = value
+        self.setWindowTitle(self.report_strategy.title())
+        self.setInformativeText(self.report_strategy.summary())
+        self.setDetailedText(self.report_strategy.report())
diff --git a/speedwagon/frontend/qtwidgets/dialog/settings.py b/speedwagon/frontend/qtwidgets/dialog/settings.py
index 5ecb807ae..59fbb1b23 100644
--- a/speedwagon/frontend/qtwidgets/dialog/settings.py
+++ b/speedwagon/frontend/qtwidgets/dialog/settings.py
@@ -1,10 +1,18 @@
 """Configuration settings."""
-
+from __future__ import annotations
+import abc
 import os
 import platform
 import typing
-from typing import Optional, Dict, cast, Type
+from typing import (
+    Optional, Dict, cast, List, Tuple, Callable, Iterable, TYPE_CHECKING
+)
+import sys
+
 from PySide6 import QtWidgets, QtCore  # type: ignore
+
+import speedwagon
+
 try:  # pragma: no cover
     from importlib.resources import as_file
     from importlib import resources
@@ -12,20 +20,31 @@
     from importlib_resources import as_file
     import importlib_resources as resources  # type: ignore
 
-from speedwagon.config import \
-    AbsOpenSettings, \
-    DarwinOpenSettings, \
-    WindowsOpenSettings, \
-    OpenSettingsDirectory
-
-from speedwagon import job
+from speedwagon import config
 from speedwagon.frontend import qtwidgets
+from speedwagon.frontend.qtwidgets.widgets import (
+    PluginConfig,
+    WorkflowSettingsEditor
+)
+from speedwagon.frontend.qtwidgets.models import tabs as tab_models
+from speedwagon.frontend.qtwidgets.models.settings import\
+    unpack_global_settings_model,\
+    build_setting_qt_model
 from speedwagon.frontend.qtwidgets import models
+if TYPE_CHECKING:
+    from speedwagon.frontend.qtwidgets.models import plugins as plugin_models
+
+if sys.version_info < (3, 10):  # pragma: no cover
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
 
 __all__ = ['GlobalSettingsTab', 'TabsConfigurationTab', 'TabEditor']
 
+SaveCallback = Callable[["SettingsTab"], Dict[str, typing.Any]]
 
-class UnsupportedOpenSettings(AbsOpenSettings):
+
+class UnsupportedOpenSettings(config.AbsOpenSettings):
 
     def __init__(self,
                  settings_directory: str,
@@ -44,7 +63,21 @@ def system_open_directory(self, settings_directory: str) -> None:
         msg.show()
 
 
+class SettingsTab(QtWidgets.QWidget):
+    changes_made = QtCore.Signal()
+
+    def data_is_modified(self) -> bool:
+        raise NotImplementedError(
+            f"required method, data_is_modified has not be implemented in "
+            f"{self.__class__.__name__}"
+        )
+
+    def get_data(self) -> Dict[str, typing.Any]:
+        return {}
+
+
 class SettingsDialog(QtWidgets.QDialog):
+    changes_made = QtCore.Signal()
 
     def __init__(
             self,
@@ -64,7 +97,7 @@ def __init__(
 
         # pylint: disable=no-member
         # pylint: disable=unnecessary-lambda
-        # This needs a lambda to delay execution. Otherwise Qt might segfault
+        # This needs a lambda to delay execution. Otherwise, Qt might segfault
         # when it tries to open the dialog box
         self.open_settings_path_button.clicked.connect(  # type: ignore
             lambda: self.open_settings_dir()
@@ -73,7 +106,7 @@ def __init__(
 
         layout.addWidget(self.open_settings_path_button)
 
-        self._button_box = \
+        self.button_box = \
             QtWidgets.QDialogButtonBox(
                 cast(
                     QtWidgets.QDialogButtonBox.StandardButton,
@@ -82,45 +115,158 @@ def __init__(
                 )
             )
 
-        self._button_box.accepted.connect(  # type: ignore
+        # Okay button will start out disabled because no changes have been made
+        self.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).setEnabled(False)
+
+        self.button_box.accepted.connect(  # type: ignore
             self.accept
         )
-        self._button_box.rejected.connect(  # type: ignore
+        self.button_box.rejected.connect(  # type: ignore
             self.reject
         )
-        layout.addWidget(self._button_box)
+        layout.addWidget(self.button_box)
 
         self.setLayout(layout)
-        self.setFixedHeight(480)
-        self.setFixedWidth(600)
+        self.setMinimumHeight(480)
+        self.setMinimumWidth(600)
+        self.changes_made.connect(self._handle_changes)
+
+    @property
+    def settings_tabs(self) -> Dict[str, SettingsTab]:
+        settings: Dict[str, SettingsTab] = {}
+        for index in range(self.tabs_widget.count()):
+            settings[self.tabs_widget.tabText(index)] = \
+                typing.cast(
+                    SettingsTab,
+                    self.tabs_widget.widget(index)
+                )
 
-    def add_tab(self, tab: QtWidgets.QWidget, tab_name: str) -> None:
+            self.tabs_widget.widget(index)
+        return settings
+
+    def _handle_changes(self) -> None:
+        modified = False
+        for tab in self.settings_tabs.values():
+            if tab.data_is_modified():
+                modified = True
+
+        # This is an odd quirk with the current version of PySide6 (6.4.2) as
+        # of this writing. For some reason, StandardButton buttons won't update
+        # when called in a slot. Therefore, the buttons uses have to be
+        # completely assigned  new buttons with updated attributes to see any
+        # changes.
+        self.button_box.setStandardButtons(
+            QtWidgets.QDialogButtonBox.StandardButton.Cancel |
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok_button = self.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok_button.setEnabled(modified)
+
+    def add_tab(self, tab: SettingsTab, tab_name: str) -> None:
+        tab.changes_made.connect(self.changes_made)
         self.tabs_widget.addTab(tab, tab_name)
 
     def open_settings_dir(
             self,
-            strategy: Optional[AbsOpenSettings] = None
+            strategy: Optional[config.AbsOpenSettings] = None
     ) -> None:
         if self.settings_location is None:
             return
 
-        strategies: Dict[str, AbsOpenSettings] = {
-            "Darwin": DarwinOpenSettings(self.settings_location),
-            "Windows": WindowsOpenSettings(self.settings_location)
+        strategies: Dict[str, config.AbsOpenSettings] = {
+            "Darwin": config.DarwinOpenSettings(self.settings_location),
+            "Windows": config.WindowsOpenSettings(self.settings_location)
         }
 
-        folder_opener = OpenSettingsDirectory(
+        folder_opener = config.OpenSettingsDirectory(
             strategy if strategy is not None else strategies.get(
                 platform.system(),
                 UnsupportedOpenSettings(
                     settings_directory=self.settings_location, parent=self)
             )
         )
-
         folder_opener.open()
 
 
-class GlobalSettingsTab(QtWidgets.QWidget):
+class PluginModelLoader(abc.ABC):  # pylint: disable=too-few-public-methods
+    @abc.abstractmethod
+    def load_plugins_into_model(
+            self,
+            model: plugin_models.PluginActivationModel
+    ) -> None:
+        """Load plugins into the model."""
+
+
+class EntrypointsPluginModelLoader(PluginModelLoader):
+    entrypoint_group_name = 'speedwagon.plugins'
+
+    def __init__(self, config_file: str) -> None:
+        super().__init__()
+        self.config_file = config_file
+
+    @classmethod
+    def plugin_entry_points(cls) -> metadata.EntryPoints:
+        return metadata.entry_points(
+            group=cls.entrypoint_group_name
+        )
+
+    def is_entry_point_active(self, entry_point: metadata.EntryPoint) -> bool:
+        settings = config.read_settings_file_plugins(self.config_file)
+        return (
+            entry_point.module in settings
+            and entry_point.name in settings[entry_point.module]
+            and settings[entry_point.module][entry_point.name] is True
+        )
+
+    def load_plugins_into_model(
+            self,
+            model: plugin_models.PluginActivationModel
+    ) -> None:
+        for entry_point in self.plugin_entry_points():
+            model.add_entry_point(
+                entry_point, self.is_entry_point_active(entry_point)
+            )
+
+
+class PluginsTab(SettingsTab):
+    def __init__(
+            self,
+            parent: Optional[QtWidgets.QWidget] = None,
+    ) -> None:
+        super().__init__(parent)
+        layout = QtWidgets.QVBoxLayout()
+        self.plugins_activation = PluginConfig(self)
+        layout.addWidget(self.plugins_activation)
+        self.setLayout(layout)
+        self.plugins_activation.changes_made.connect(self.changes_made)
+
+    def data_is_modified(self) -> bool:
+        return self.plugins_activation.model.data_modified
+
+    def get_data(self) -> Dict[str, typing.Any]:
+        return {
+            "enabled_plugins": self.plugins_activation.enabled_plugins()
+        }
+
+    def load(self, settings_ini: str) -> None:
+        settings = config.read_settings_file_plugins(settings_ini)
+        for entry_point in metadata.entry_points(group='speedwagon.plugins'):
+            active = False
+            if (
+                entry_point.module in settings
+                and entry_point.name in settings[entry_point.module]
+                and settings[entry_point.module][entry_point.name] is True
+            ):
+                active = True
+
+            self.plugins_activation.model.add_entry_point(entry_point, active)
+
+
+class GlobalSettingsTab(SettingsTab):
     """Widget for editing global settings."""
 
     def __init__(
@@ -133,6 +279,11 @@ def __init__(
         self.config_file: Optional[str] = None
         self._modified = False
 
+        self._table_model: Optional[
+            models.SettingsModel] = \
+            models.SettingsModel()
+        self._table_model.dataChanged.connect(self.changes_made)
+        self.changes_made.connect(self.on_modified)
         layout = QtWidgets.QVBoxLayout(self)
 
         self.settings_table = QtWidgets.QTableView(self)
@@ -145,52 +296,54 @@ def __init__(
         layout.addWidget(self.settings_table)
         self.setLayout(layout)
 
+    def get_data(self) -> config.SettingsData:
+        """Get the global settings data."""
+        if self._table_model:
+            return \
+                unpack_global_settings_model(self._table_model)
+        return {}
+
+    def data_is_modified(self) -> bool:
+        """Get if the data has been modified since originally added."""
+        return self._modified
+
+    @property
+    def model(self) -> Optional[models.SettingsModel]:
+        """Get the model used in the table widget."""
+        return self._table_model
+
+    @model.setter
+    def model(self, value: models.SettingsModel) -> None:
+        self._table_model = value
+        self.settings_table.setModel(self._table_model)
+
     def read_config_data(self) -> None:
         """Read configuration file."""
         if self.config_file is None:
             raise FileNotFoundError("No Configuration file set")
         if not os.path.exists(self.config_file):
             raise FileNotFoundError("Invalid Configuration file set")
+        self._table_model = \
+            build_setting_qt_model(self.config_file)
 
-        self.settings_table.setModel(
-            models.build_setting_qt_model(self.config_file)
-        )
+        self.settings_table.setModel(self._table_model)
 
-        self.settings_table.model().dataChanged.connect(  # type: ignore
-            self.on_modified
+        self._table_model.dataChanged.connect(  # type: ignore
+            self.changes_made
         )
 
+    def load_ini_file(self, ini_file_path: str) -> None:
+        """Load tab widget based on data in the given ini file."""
+        self.config_file = ini_file_path
+        self.read_config_data()
+
     def on_modified(self) -> None:
         """Set modified to true."""
-        self._modified = True
+        if self._table_model:
+            self._modified = self._table_model.data_modified
 
-    def on_okay(self) -> None:
-        """Execute when a user selects okay."""
-        if not self._modified:
-            return
-        if self.config_file is None:
-            msg = QtWidgets.QMessageBox(parent=self)
-            msg.setIcon(QtWidgets.QMessageBox.Icon.Warning)
-            msg.setText("Unable to save settings. No configuration file set")
-            msg.exec()
-            return
-
-        print("Saving changes")
 
-        data = models.serialize_settings_model(
-            self.settings_table.model()
-        )
-
-        with open(self.config_file, "w", encoding="utf-8") as file_writer:
-            file_writer.write(data)
-
-        msg_box = QtWidgets.QMessageBox(self)
-        msg_box.setWindowTitle("Saved changes")
-        msg_box.setText("Please restart changes to take effect")
-        msg_box.exec()
-
-
-class TabsConfigurationTab(QtWidgets.QWidget):
+class TabsConfigurationTab(SettingsTab):
     """Tabs configuration widget."""
 
     def __init__(
@@ -204,14 +357,29 @@ def __init__(
         self._modified = False
         layout = QtWidgets.QVBoxLayout(self)
         self.editor = TabEditor()
+        self.editor.changes_made.connect(self.changes_made)
         self.editor.layout().setContentsMargins(0, 0, 0, 0)
         layout.addWidget(self.editor)
         self.setLayout(layout)
 
+    def data_is_modified(self) -> bool:
+        """Check if data has changed since originally set."""
+        return self.editor.modified
+
+    def get_data(self) -> Dict[str, typing.Any]:
+        """Get the data the user entered."""
+        return {"tab_information": self.editor.model.tab_information()}
+
+    def tab_config_management_strategy(
+            self
+    ) -> config.AbsTabsConfigDataManagement:
+        """Get the default strategy for working with custom tab YAML files."""
+        if self.settings_location is None:
+            raise RuntimeError("settings_location not set")
+        return config.CustomTabsYamlConfig(self.settings_location)
+
     def on_okay(self) -> None:
         """Execute when a user selects okay."""
-        if self.editor.modified is False:
-            return
         if self.settings_location is None:
             msg = QtWidgets.QMessageBox(parent=self)
             msg.setIcon(QtWidgets.QMessageBox.Icon.Warning)
@@ -219,12 +387,12 @@ def on_okay(self) -> None:
             msg.exec()
             return
         print(f"Saving changes to {self.settings_location}")
-        qtwidgets.tabs.write_tabs_yaml(
-            self.settings_location,
-            qtwidgets.tabs.extract_tab_information(
-                cast(
-                    models.TabsModel,
-                    self.editor.selected_tab_combo_box.model()
+        yaml_tab_config = self.tab_config_management_strategy()
+        yaml_tab_config.save(
+            list(
+                filter(
+                    lambda tab: tab.tab_name != "All",
+                    self.editor.model.tab_information(),
                 )
             )
         )
@@ -234,79 +402,283 @@ def on_okay(self) -> None:
         msg_box.setText("Please restart changes to take effect")
         msg_box.exec()
 
-    def load(self) -> None:
+    def load(
+            self,
+            strategy: tab_models.AbsLoadTabDataModelStrategy
+    ) -> None:
         """Load configuration settings."""
-        print(f"loading {self.settings_location}")
-        self.editor.tabs_file = self.settings_location
-        workflows = job.available_workflows()
-        self.editor.set_all_workflows(workflows)
+        strategy.load(self.editor.model)
+        self.editor.selected_tab_combo_box.setCurrentIndex(0)
 
 
-class SettingsBuilder:
-    def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = None):
-        """Create a new settings dialog builder.
+class ConfigWorkflowSettingsTab(SettingsTab):
 
-        Args:
-            parent: Parent widget of the settings window.
-        """
-        self._parent = parent
-        self._settings_path: typing.Optional[str] = None
-        self._global_config_file_path: typing.Optional[str] = None
-        self._tab_config_file_path: typing.Optional[str] = None
-        self._global_settings_tab: typing.Optional[GlobalSettingsTab] = None
-
-    def _build_tab_editor_tab(self, config_dialog: SettingsDialog) -> None:
-        tabs_tab = TabsConfigurationTab()
-        if self._settings_path is not None:
-            tabs_tab.settings_location = self._tab_config_file_path
-            tabs_tab.load()
-
-        config_dialog.add_tab(tabs_tab, "Tabs")
-        config_dialog.accepted.connect(  # type: ignore
-            tabs_tab.on_okay
-        )
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        layout = QtWidgets.QVBoxLayout(self)
+        self._editor = WorkflowSettingsEditor(self)
+        layout.addWidget(self._editor)
+        self.setLayout(layout)
+        self._editor.workflow_settings_view.header().resizeSection(0, 300)
+        self.model = models.WorkflowSettingsModel()
+        self._editor.model = self.model
+
+    def set_workflows(self, workflows: Iterable[speedwagon.Workflow]):
+        self.model.clear()
+        for workflow in workflows:
+            self.model.add_workflow(workflow)
+        self.model.reset_modified()
+        self._editor.workflow_settings_view.expandAll()
+        self.model.dataChanged.connect(self.changes_made)
+
+    def data_is_modified(self) -> bool:
+        return self.model.modified()
+
+    def get_data(self) -> Dict[str, typing.Any]:
+        return {"workflow settings": self.model.results()}
+
+
+class AbsConfigSaver2(abc.ABC):  # pylint: disable=too-few-public-methods
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__()
+        self.parent = parent
+
+    @abc.abstractmethod
+    def save(self) -> None:
+        """Save to file."""
 
-    def _build_global_settings(self, config_dialog: SettingsDialog) -> None:
-        global_settings_tab = GlobalSettingsTab()
-        if self._global_config_file_path is not None:
-            global_settings_tab.config_file = self._global_config_file_path
-            global_settings_tab.read_config_data()
 
-        config_dialog.accepted.connect(  # type: ignore
-            global_settings_tab.on_okay
+class AbsSaveStrategy(abc.ABC):
+
+    @abc.abstractmethod
+    def write_file(self, data: str, file: str) -> None:
+        """Write data to a file."""
+
+    @abc.abstractmethod
+    def serialize_data(self) -> str:
+        """Serialize data into a string."""
+
+
+class AbsConfigSaverCallbacks(AbsConfigSaver2, abc.ABC):
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        self._success_callbacks: List[
+            Callable[[Optional[QtWidgets.QWidget]], None]
+        ] = []
+
+    def add_success_call_back(
+            self,
+            callback: typing.Callable[[Optional[QtWidgets.QWidget]], None]
+    ) -> None:
+        self._success_callbacks.append(callback)
+
+    def notify_success(self):
+        for callback in self._success_callbacks:
+            callback(self.parent)
+
+
+class MultiSaver(AbsConfigSaverCallbacks):
+    def __init__(
+            self,
+            config_savers: Optional[List[AbsConfigSaver2]] = None,
+            parent: Optional[QtWidgets.QWidget] = None
+    ) -> None:
+        super().__init__(parent)
+        self.config_savers: list[AbsConfigSaver2] = config_savers or []
+
+    def save(self):
+        for saver in self.config_savers:
+            saver.save()
+        self.notify_success()
+
+
+class AbsConfigSaver(abc.ABC):  # pylint: disable=too-few-public-methods
+    def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None:
+        super().__init__()
+        self.parent = parent
+
+    @abc.abstractmethod
+    def save(self, tab_widgets: Dict[str, SettingsTab]) -> None:
+        """Save data to a file."""
+
+
+class ConfigFileSaver(AbsConfigSaverCallbacks):
+
+    def __init__(
+            self,
+            save_strategy: AbsSaveStrategy,
+            file_path: str,
+            parent: Optional[QtWidgets.QWidget] = None
+    ) -> None:
+        super().__init__(parent)
+        self.save_strategy = save_strategy
+        self.file_path = file_path
+
+    def save(self) -> None:
+        data = self.save_strategy.serialize_data()
+        self.save_strategy.write_file(data, self.file_path)
+
+
+class SaveStrategy(AbsSaveStrategy, abc.ABC):
+    def write_file(self, data: str, file: str) -> None:
+        with open(file, 'w', encoding="utf8") as file_handel:
+            file_handel.write(data)
+
+
+class SettingsTabSaveStrategy(SaveStrategy):
+    def __init__(
+            self,
+            settings_tab_widget: SettingsTab,
+            serialization_function: Callable[[SettingsTab], str]) -> None:
+        self.widget = settings_tab_widget
+        self.serialization_function = serialization_function
+
+    def serialize_data(self) -> str:
+        return self.serialization_function(self.widget)
+
+
+class ConfigSaver(AbsConfigSaver):
+
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        self.tabs_yaml_path: Optional[str] = None
+        self._success_callbacks: List[
+            typing.Callable[[Optional[QtWidgets.QWidget]], None]
+        ] = []
+        self.file_manager: Optional[config.IniConfigManager] = None
+
+    @property
+    def config_file_path(self) -> Optional[str]:
+        return (
+            None if self.file_manager is None
+            else self.file_manager.config_file
         )
-        self._global_settings_tab = global_settings_tab
 
-        if self._global_settings_tab is not None:
-            config_dialog.add_tab(self._global_settings_tab, "Global Settings")
+    @config_file_path.setter
+    def config_file_path(self, value: str) -> None:
+        self.file_manager = config.IniConfigManager(value)
+
+    def add_success_call_back(
+            self,
+            callback: typing.Callable[[Optional[QtWidgets.QWidget]], None]
+    ) -> None:
+        self._success_callbacks.append(callback)
+
+    @staticmethod
+    def _get_global_data(
+            tab_widgets: Dict[str, SettingsTab]
+    ) -> config.SettingsData:
+        global_data: config.SettingsData = {}
+
+        global_settings_tab: Optional[SettingsTab] = \
+            tab_widgets.get("Global Settings")
+
+        if global_settings_tab:
+            global_data = {
+                **global_data,
+                **(global_settings_tab.get_data() or {})
+            }
+        return global_data
+
+    def get_tab_config_strategy(
+            self
+    ) -> config.AbsTabsConfigDataManagement:
+        if not self.tabs_yaml_path:
+            raise RuntimeError("ConfigSaver.tabs_yaml_path not set")
+        return config.CustomTabsYamlConfig(self.tabs_yaml_path)
+
+    def _save_tabs(self, editor: TabEditor) -> None:
+        yaml_config = self.get_tab_config_strategy()
+        yaml_config.save(list(
+            filter(
+                lambda tab: tab.tab_name != "All",
+                editor.model.tab_information(),
+            )
+        ))
+
+    def _save_config(self, tab_widgets: Dict[str, SettingsTab]) -> None:
+        if self.file_manager is None:
+            return
+        config_data = {
+            "GLOBAL": self._make_config_parsable(
+                self._get_global_data(tab_widgets)
+            )
+        }
+        self.file_manager.save(config_data)
+
+    def save(self, tab_widgets: Dict[str, SettingsTab]) -> None:
+        self._save_config(tab_widgets)
+        tabs_tab = cast(TabsConfigurationTab, tab_widgets["Tabs"])
+        self._save_tabs(tabs_tab.editor)
+        self.on_success()
+
+    def on_success(self) -> None:
+        for callback in self._success_callbacks:
+            callback(self.parent)
+
+    @staticmethod
+    def _make_config_parsable(
+            source_data: typing.Union[Dict[str, bool], config.SettingsData]
+    ) -> config.SettingsData:
+        return {
+            key: str(value)
+            for key, value in source_data.items()
+        }
 
-    def build(self) -> SettingsDialog:
-        """Generate a new SettingsDialog object."""
-        config_dialog = SettingsDialog(parent=self._parent)
-        if self._settings_path is not None:
-            config_dialog.settings_location = self._settings_path
-        else:
-            config_dialog.open_settings_path_button.setVisible(False)
 
-        if self._global_config_file_path is not None:
-            self._build_global_settings(config_dialog)
+class SettingsBuilder2:
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None):
+        self._parent = parent
+        self._tabs: List[Tuple[str, SettingsTab]] = []
+        self._on_save_callback: Optional[
+            Callable[[SettingsDialog, Dict[str, SettingsTab]], None]
+        ] = None
+        self._save_strategy: Optional[AbsConfigSaverCallbacks] = None
+        self.app_data_dir: Optional[str] = None
 
-        if self._tab_config_file_path is not None:
-            self._build_tab_editor_tab(config_dialog)
+    def build(self) -> SettingsDialog:
+        config_dialog = SettingsDialog(parent=self._parent)
+        if self.app_data_dir:
+            config_dialog.settings_location = self.app_data_dir
+        for name, tab in self._tabs:
+            tab.setParent(config_dialog.tabs_widget)
+            tab.changes_made.connect(config_dialog.changes_made)
+            config_dialog.add_tab(tab, name)
+
+        if self._save_strategy is not None:
+            config_dialog.accepted.connect(  # type: ignore
+                self._save_strategy.save
+            )
 
+        if self._on_save_callback is not None:
+            config_dialog.accepted.connect(
+                lambda callback=self._on_save_callback: callback(
+                    config_dialog,
+                    config_dialog.settings_tabs
+                )
+            )
         return config_dialog
 
-    def add_global_settings(self, path: str) -> None:
-        self._global_config_file_path = path
+    def add_tab(
+            self,
+            name: str,
+            widget: SettingsTab,
+    ) -> None:
+        self._tabs.append((name, widget))
 
-    def add_tabs_setting(self, path: str) -> None:
-        self._tab_config_file_path = path
+    def set_saver_strategy(self, value: AbsConfigSaverCallbacks) -> None:
+        self._save_strategy = value
 
-    def add_open_settings(self, path: str) -> None:
-        self._settings_path = path
+    def add_on_save_callback(
+            self,
+            on_save_callback: Callable[
+                [SettingsDialog, Dict[str, SettingsTab]], None
+            ]
+    ) -> None:
+        self._on_save_callback = on_save_callback
 
 
-class TabEditorWidget(QtWidgets.QWidget):
+class TabEditorWidgetUI(QtWidgets.QWidget):  # pylint: disable=R0903
     tab_workflows_list_view: QtWidgets.QListView
     selected_tab_combo_box: QtWidgets.QComboBox
     new_tab_button: QtWidgets.QPushButton
@@ -334,10 +706,14 @@ def load_ui_file(self) -> None:
             qtwidgets.ui_loader.load_ui(str(ui_file), self)
 
 
-class TabEditor(TabEditorWidget):
+class TabEditor(TabEditorWidgetUI):
     """Widget for editing tabs."""
 
     all_workflows_list_view: QtWidgets.QListView
+    _tabs_model: models.TabsTreeModel
+
+    current_tab_index_changed = QtCore.Signal(QtCore.QModelIndex)
+    changes_made = QtCore.Signal()
 
     def __init__(
             self,
@@ -346,24 +722,39 @@ def __init__(
     ) -> None:
         """Create a tab editor widget."""
         super().__init__(parent, flags)
-        self.tabs_model = models.TabsModel()
-        self.selected_tab_combo_box.setModel(self.tabs_model)
+        self.load_tab_data_model_strategy: \
+            tab_models.AbsLoadTabDataModelStrategy = \
+            tab_models.TabDataModelConfigLoader()
+
+        self.model = models.TabsTreeModel()
+        self._user_tabs_model = QtCore.QSortFilterProxyModel()
+
+        self._user_tabs_model.setDynamicSortFilter(True)
+
+        self._user_tabs_model.setFilterRegularExpression("^((?!All).)*$")
+        self._user_tabs_model.setSourceModel(self._tabs_model)
+
+        self._tabs_model.dataChanged.connect(  # type: ignore
+            self.changes_made
+        )
+        self.selected_tab_combo_box.setModel(self._user_tabs_model)
 
         self._tabs_file: Optional[str] = None
 
-        self._all_workflows_model: models.WorkflowListModel2 = \
-            models.WorkflowListModel2()
+        self._all_workflows_model = tab_models.TabProxyModel()
+        self._all_workflows_model.setSourceModel(self._tabs_model)
+        self._all_workflows_model.set_source_tab("All")
+        self.all_workflows_list_view.setModel(self._all_workflows_model)
 
-        self._active_tab_workflows_model: \
-            models.WorkflowListModel2 = \
-            models.WorkflowListModel2()
+        self._active_tab_workflows_model = tab_models.TabProxyModel()
+        self._active_tab_workflows_model.setSourceModel(self._tabs_model)
 
         self.tab_workflows_list_view.setModel(
             self._active_tab_workflows_model
         )
-        selected_tab_combo_box = self.selected_tab_combo_box
-        selected_tab_combo_box.currentIndexChanged.connect(  # type: ignore
-            self._changed_tab
+        self.tab_workflows_list_view.setAlternatingRowColors(True)
+        self.selected_tab_combo_box.currentIndexChanged.connect(
+            self._handle_current_tab_index_changed
         )
 
         self.new_tab_button.clicked.connect(  # type: ignore
@@ -380,50 +771,55 @@ def __init__(
         self.remove_items_button.clicked.connect(  # type: ignore
             self._remove_items
         )
-
-        # pylint: disable=no-member
-        self.tabs_model.dataChanged.connect(self.on_modified)  # type: ignore
-        self.modified: bool = False
         self.splitter.setChildrenCollapsible(False)
 
-    def on_modified(self) -> None:
-        """Set modified to true."""
-        self.modified = True
+    def _handle_current_tab_index_changed(self, index: int) -> None:
+        base_index = self._user_tabs_model.mapToSource(
+            self.selected_tab_combo_box.model().index(index, 0)
+        )
 
-    @property
-    def tabs_file(self) -> Optional[str]:
-        """Get tabs file used."""
-        return self._tabs_file
+        self._active_tab_workflows_model.set_source_tab(
+            self.model.data(base_index)
+        )
+        self.current_tab_index_changed.emit(base_index)
 
-    @tabs_file.setter
-    def tabs_file(self, value: str) -> None:
+    @property
+    def modified(self) -> bool:
+        """Get if editor data has been modified since originally added."""
+        if self._tabs_model.data_modified is True:
+            return self._tabs_model.data_modified
 
-        for tab in qtwidgets.tabs.read_tabs_yaml(value):
+        return any(tab.data_modified for tab in self._tabs_model.tabs)
 
-            tab.workflows_model.dataChanged.connect(  # type: ignore
-                self.on_modified
+    @property
+    def model(self) -> models.TabsTreeModel:
+        """Get the model used by the editor widget."""
+        return self._tabs_model
+
+    @model.setter
+    def model(self, value: models.TabsTreeModel) -> None:
+        if hasattr(self, "_tabs_model"):
+            self._tabs_model.dataChanged.disconnect(  # type: ignore
+                self.changes_made
             )
-            self.tabs_model.add_tab(tab)
-        self.selected_tab_combo_box.setCurrentIndex(0)
-        self._tabs_file = value
-        self.modified = False
-
-    def _changed_tab(self, tab: int) -> None:
-        model: QtCore.QAbstractListModel = cast(
-            models.TabsModel,
-            self.selected_tab_combo_box.model()
+        self._tabs_model = value
+        self._tabs_model.dataChanged.connect(  # type: ignore
+            self.changes_made
         )
-        index = model.index(tab)
-        if index.isValid():
-            data = model.data(
-                index,
-                role=typing.cast(int, QtCore.Qt.ItemDataRole.UserRole)
-            )
-            self.tab_workflows_list_view.setModel(data.workflows_model)
-        else:
-            self.tab_workflows_list_view.setModel(
-                models.WorkflowListModel2()
-            )
+        self.selected_tab_combo_box.setModel(self._tabs_model)
+
+    def load_data(self) -> None:
+        """Load tab data into model."""
+        self.load_tab_data_model_strategy.load(self.model)
+        self.all_workflows_list_view.setModel(self._all_workflows_model)
+        self._all_workflows_model.set_source_tab("All")
+
+    def set_current_tab(self, tab_name: str) -> None:
+        """Set current tab."""
+        new_index = self.selected_tab_combo_box.findText(tab_name)
+        if new_index < 0:
+            raise ValueError(f'"{tab_name}" not found in combo box')
+        self.selected_tab_combo_box.setCurrentIndex(new_index)
 
     def _create_new_tab(self) -> None:
         while True:
@@ -436,7 +832,7 @@ def _create_new_tab(self) -> None:
             if not accepted:
                 return
 
-            if new_tab_name in self.tabs_model:
+            if self._tabs_model.get_tab(new_tab_name):
                 message = f"Tab named \"{new_tab_name}\" already exists."
                 error = QtWidgets.QMessageBox(self)
                 error.setText(message)
@@ -445,54 +841,68 @@ def _create_new_tab(self) -> None:
                 error.exec()
                 continue
 
-            new_tab = qtwidgets.tabs.TabData(
-                new_tab_name,
-                models.WorkflowListModel2()
-            )
-
-            self.tabs_model.add_tab(new_tab)
-            new_index = self.selected_tab_combo_box.findText(new_tab_name)
-            self.selected_tab_combo_box.setCurrentIndex(new_index)
+            self._tabs_model.append_workflow_tab(new_tab_name)
+            self.set_current_tab(new_tab_name)
+            self.changes_made.emit()
             return
 
     def _delete_tab(self) -> None:
-        data = self.selected_tab_combo_box.currentData()
-        model = cast(models.TabsModel, self.selected_tab_combo_box.model())
-        model.remove_tab(data)
+        starting_index = self.selected_tab_combo_box.currentIndex()
+        index = self._user_tabs_model.index(starting_index, 0)
+        source_index = self._user_tabs_model.mapToSource(index)
+
+        self.model.beginRemoveRows(
+            source_index,
+            source_index.row(),
+            source_index.row()
+        )
+
+        self.selected_tab_combo_box.model().beginRemoveRows(
+            index, index.row(), index.row()
+        )
+        self.model.removeRow(source_index.row())
+        self.selected_tab_combo_box.model().endRemoveRows()
+        self.model.endRemoveRows()
+        self.model.modelReset.emit()
+        self.changes_made.emit()
 
     def _add_items_to_tab(self) -> None:
         model = cast(
-            models.WorkflowListModel2,
+            tab_models.TabProxyModel,
             self.tab_workflows_list_view.model()
         )
-        for i in self.all_workflows_list_view.selectedIndexes():
-            new_workflow = i.data(role=QtCore.Qt.ItemDataRole.UserRole)
+        for selected_index in self.all_workflows_list_view.selectedIndexes():
+            new_workflow = \
+                self._all_workflows_model.data(
+                    selected_index,
+                    role=models.WorkflowClassRole
+                )
+            active_tab_index = self._active_tab_workflows_model.get_tab_index()
+            starting_row_count = model.rowCount()
+
             model.add_workflow(new_workflow)
+            self.model.dataChanged.emit(
+                self.model.index(starting_row_count, parent=active_tab_index),
+                self.model.index(model.rowCount(), parent=active_tab_index)
+            )
         model.sort(0)
 
     def _remove_items(self) -> None:
         model = cast(
-            models.WorkflowListModel2,
+            models.WorkflowListProxyModel,
             self.tab_workflows_list_view.model()
         )
         items_to_remove = [
-            i.data(role=QtCore.Qt.ItemDataRole.UserRole)
+            i.data(role=models.WorkflowClassRole)
             for i in self.tab_workflows_list_view.selectedIndexes()
         ]
 
         for item in items_to_remove:
             model.remove_workflow(item)
-        model.sort()
+        self.changes_made.emit()
 
-    def set_all_workflows(
-            self,
-            workflows: Dict[str, Type[job.Workflow]]
-    ) -> None:
+    def set_all_workflows(self) -> None:
         """Set up all workflows."""
-        for values in workflows.values():
-            self._all_workflows_model.add_workflow(values)
-
-        self._all_workflows_model.sort(0)
         self.all_workflows_list_view.setModel(self._all_workflows_model)
 
     @property
diff --git a/speedwagon/frontend/qtwidgets/gui.py b/speedwagon/frontend/qtwidgets/gui.py
index f129672da..26bf96b45 100644
--- a/speedwagon/frontend/qtwidgets/gui.py
+++ b/speedwagon/frontend/qtwidgets/gui.py
@@ -3,16 +3,15 @@
 Mainly for connecting GUI elements, such as buttons, to functions and methods
 that do the work
 """
+
 from __future__ import annotations
 
-import io
+import contextlib
 import logging
 import logging.handlers
-import os
-import time
 import typing
-import webbrowser
-from typing import List, Optional, Dict, Union
+from typing import List, Optional, Dict
+
 
 try:  # pragma: no cover
     from importlib import metadata
@@ -32,58 +31,34 @@
 
 import speedwagon
 from speedwagon.frontend import qtwidgets
-from speedwagon.frontend.qtwidgets import widgets, models
+from speedwagon.frontend.qtwidgets import widgets, models, logging_helpers
+from speedwagon.frontend.qtwidgets.models import tabs as tab_models
+from speedwagon.frontend.qtwidgets.models import options as option_models
 import speedwagon.runner_strategies
 from speedwagon.job import Workflow
-if typing.TYPE_CHECKING:
-    from speedwagon.workflow import AbsOutputOptionDataType
-    from speedwagon.worker import AbsToolJobManager
+from speedwagon.workflow import AbsOutputOptionDataType
+from speedwagon.config import StandardConfig, FullSettingsData
 
 __all__ = [
-    "MainWindow1"
+    "MainWindow3"
 ]
 
 DEBUG_LOGGING_FORMAT = logging.Formatter(
     '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 
-TAB_WIDGET_SIZE_POLICY = QtWidgets.QSizePolicy(
-    QtWidgets.QSizePolicy.Policy.MinimumExpanding,
-    QtWidgets.QSizePolicy.Policy.Maximum
-)
-
-CONSOLE_SIZE_POLICY = QtWidgets.QSizePolicy(
-    QtWidgets.QSizePolicy.Policy.MinimumExpanding,
-    QtWidgets.QSizePolicy.Policy.Minimum
-)
 
 Setting = namedtuple("Setting", ("installed_packages_title", "widget"))
 
 
 class ToolConsole(QtWidgets.QWidget):
     """Logging console."""
-
-    class ConsoleLogHandler(logging.handlers.BufferingHandler):
-        class Signals(QtCore.QObject):
-            message = QtCore.Signal(str)
-
-        def __init__(self, console_widget: "ToolConsole"):
-            super().__init__(capacity=10)
-            self.signals = ToolConsole.ConsoleLogHandler.Signals()
-            self.console_widget = console_widget
-            self.signals.message.connect(self.console_widget.add_message)
-
-        def flush(self) -> None:
-            if len(self.buffer) > 0:
-                message_buffer = [
-                    self.format(record) for record in self.buffer
-                ]
-                message = "".join(message_buffer).strip()
-                self.signals.message.emit(message)
-            super().flush()
+    _console: QtWidgets.QTextBrowser
 
     def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
         super().__init__(parent)
-        self.log_handler = ToolConsole.ConsoleLogHandler(self)
+
+        self.log_handler = logging_helpers.QtSignalLogHandler(self)
+        self.log_handler.signals.messageSent.connect(self.add_message)
 
         self.log_formatter = qtwidgets.logging_helpers.ConsoleFormatter()
         self.log_handler.setFormatter(self.log_formatter)
@@ -92,13 +67,8 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
                 resources.files(qtwidgets.ui).joinpath("console.ui")
         ) as ui_file:
             qtwidgets.ui_loader.load_ui(str(ui_file), self)
-
-        # ======================================================================
-        # Type hints:
-        self._console: QtWidgets.QTextBrowser
-        # ======================================================================
-
-        #  Use a monospaced font based on what's on system running
+        #
+        # #  Use a monospaced font based on what's on system running
         monospaced_font = \
             QtGui.QFontDatabase.systemFont(
                 QtGui.QFontDatabase.SystemFont.FixedFont
@@ -112,11 +82,7 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
         self._console.setFont(monospaced_font)
 
         self._attached_logger: typing.Optional[logging.Logger] = None
-        self.cursor = QtGui.QTextCursor(self._log)
-
-    def close(self) -> bool:
-        self.detach_logger()
-        return super().close()
+        self.cursor: QtGui.QTextCursor = QtGui.QTextCursor(self._log)
 
     def _follow_text(self) -> None:
         cursor = QtGui.QTextCursor(self._log)
@@ -129,9 +95,9 @@ def add_message(
             message: str,
     ) -> None:
 
-        self.cursor.movePosition(self.cursor.MoveOperation.End)
+        # self.cursor.movePosition(self.cursor.MoveOperation.End)
         self.cursor.beginEditBlock()
-        self._console.setTextCursor(self.cursor)
+        # self._console.setTextCursor(self.cursor)
         self.cursor.insertHtml(message)
         self.cursor.endEditBlock()
 
@@ -150,700 +116,204 @@ def detach_logger(self) -> None:
             self._attached_logger = None
 
 
-class ItemTabsWidget(QtWidgets.QWidget):
-
+class ItemTabsUI(QtWidgets.QWidget):
     def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
         super().__init__(parent)
         with as_file(
                 resources.files(qtwidgets.ui).joinpath("setup_job.ui")
         ) as ui_file:
             qtwidgets.ui_loader.load_ui(str(ui_file), self)
-        # ======================================================================
-        # Type Hints
-        self.tabs: QtWidgets.QTabWidget
-        # ======================================================================
-        self.layout().addWidget(self.tabs)
-
-    def add_tab(self, tab: QtWidgets.QWidget, name: str) -> None:
-        self.tabs.addTab(tab, name)
-
-
-class MainProgram(QtWidgets.QMainWindow):
-    def __init__(
-            self,
-            work_manager: AbsToolJobManager,
-            debug: bool = False
-    ) -> None:
-        super().__init__()
-
-        self._debug = debug
-        self.user_settings = None
-
-        self.work_manager = work_manager
-
-        self.log_manager: logging.Logger = self.work_manager.logger
-        self.log_manager.setLevel(logging.DEBUG)
-
-    def debug_mode(self, debug: bool) -> None:
-        self._debug = debug
-
-
-class MainWindowMenuBuilder:
-    def __init__(self, parent: MainWindow2) -> None:
-        self._parent = parent
-        self._menu_bar: QtWidgets.QMenuBar = self._parent.menuBar()
-
-        self.add_help: bool = True
-        self.add_about: bool = True
-
-        self.show_configuration_signal: \
-            typing.Optional[typing.Callable[[], None]] = None
-
-        self.show_system_info_signal: \
-            typing.Optional[typing.Callable[[], None]] = None
-
-        self.exit_function: typing.Optional[typing.Callable[[], bool]] = None
-
-        self.save_log_function: \
-            typing.Optional[typing.Callable[[], None]] = None
-
-        self.export_signal: typing.Optional[typing.Callable[[], None]] = None
-        self.import_signal: typing.Optional[typing.Callable[[], None]] = None
-
-    def build(self) -> None:
-        self._build_file_menu(self._menu_bar)
-
-        self._build_job_menu(self._menu_bar)
-        self._build_system_menu(self._menu_bar)
-
-        self._build_help(self._menu_bar)
-
-    def _build_system_menu(self, menu_bar: QtWidgets.QMenuBar) -> None:
-        system_menu = menu_bar.addMenu("System")
-        system_menu.setObjectName("systemMenu")
-
-        self._build_config_action(system_menu)
-        self._build_system_info_action(system_menu)
-
-    def _build_system_info_action(self, system_menu: QtWidgets.QMenu) -> None:
-        # System --> System Info
-        # Create a system info menu item
-        if self.show_system_info_signal is not None:
-
-            system_info_menu_item = QtGui.QAction(
-                "System Info",
-                self._parent
-            )
-
-            system_info_menu_item.setObjectName("systemInfoAction")
-            # pylint: disable=no-member
-            system_info_menu_item.triggered.connect(  # type: ignore
-                self.show_system_info_signal
-            )
-            system_menu.addAction(system_info_menu_item)
-
-    def _build_config_action(self, system_menu: QtWidgets.QMenu) -> None:
-        # System --> Configuration
-        # Create a system info menu item
-        if self.show_configuration_signal is not None:
-            system_settings_menu_item = \
-                QtGui.QAction("Settings", self._parent)
-
-            system_settings_menu_item.setObjectName('settingsAction')
-            # pylint: disable=no-member
-            system_settings_menu_item.triggered.connect(  # type: ignore
-                self.show_configuration_signal
-            )
-
-            system_settings_menu_item.setShortcut("Ctrl+Shift+S")
-            system_menu.addAction(system_settings_menu_item)
-
-    def _build_file_menu(self, menu_bar: QtWidgets.QMenuBar) -> None:
-
-        # File Menu
-        file_menu = menu_bar.addMenu("File")
-        self._build_export_log_action(file_menu)
-        self._build_exit_action(file_menu)
-
-    def _build_exit_action(self, file_menu: QtWidgets.QMenu) -> None:
-        # File --> Exit
-        # Create Exit button
-        if self.exit_function is not None:
-            exit_button = QtGui.QAction(" &Exit", self._parent)
-            exit_button.setObjectName("exitAction")
-
-            # pylint: disable=no-member
-            exit_button.triggered.connect(  # type: ignore
-                self.exit_function
-            )
-            file_menu.addAction(exit_button)
-
-    def _build_export_log_action(self, file_menu: QtWidgets.QMenu) -> None:
-        # File --> Export Log
-        if self.save_log_function is not None:
-
-            export_logs_button = QtGui.QAction(
-                " &Export Log",
-                self._parent
-            )
-
-            export_logs_button.setIcon(
-                self._parent.style().standardIcon(
-                    QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton)
-            )
-            # pylint: disable=no-member
-            export_logs_button.triggered.connect(  # type: ignore
-                self.save_log_function
-            )
-            file_menu.addAction(export_logs_button)
-            file_menu.setObjectName("fileMenu")
-            file_menu.addAction(export_logs_button)
-            file_menu.setObjectName("fileMenu")
-            file_menu.addSeparator()
-
-    def _build_help(self, menu_bar: QtWidgets.QMenuBar) -> None:
-        # Help Menu
-        help_menu = menu_bar.addMenu("Help")
-
-        if self.add_help is True:
-            # Help --> Help
-            # Create a Help menu item
-            help_button = QtGui.QAction(" &Help ", self._parent)
-            # pylint: disable=no-member
-            help_button.triggered.connect(  # type: ignore
-                self._parent.help_requested
-            )
-            help_menu.addAction(help_button)
-
-        if self.add_about is True:
-            # Help --> About
-            # Create an About button
-            about_button = QtGui.QAction(" &About ", self._parent)
-            # pylint: disable=no-member
-            about_button.triggered.connect(  # type: ignore
-                self._parent.show_about_window
-            )
-            help_menu.addAction(about_button)
-
-    def _build_job_menu(self, menu_bar: QtWidgets.QMenuBar) -> None:
-        job_menu = menu_bar.addMenu("Job")
-        job_menu.setObjectName("jobMenu")
-
-        if self.export_signal is not None:
-            export_button = QtGui.QAction(
-                "Export",
-                self._parent
-            )
-
-            # pylint: disable=no-member
-            export_button.triggered.connect(self.export_signal)  # type: ignore
-            job_menu.addAction(export_button)
-
-        if self.import_signal is not None:
-            import_button = QtGui.QAction(
-                "Import",
-                self._parent
-            )
-
-            # pylint: disable=no-member
-            import_button.triggered.connect(self.import_signal)  # type: ignore
-
-            job_menu.addAction(import_button)
-
-
-class MainWindow1(MainProgram):
-    def __init__(
-            self,
-            work_manager: AbsToolJobManager,
-            debug: bool = False
-    ) -> None:
-
-        super().__init__(work_manager, debug)
-        with as_file(
-                resources.files(qtwidgets.ui).joinpath("main_window2.ui")
-        ) as ui_file:
-            self.load_ui_file(str(ui_file))
-
-        # ======================================================================
-        # Type hints
-        # ======================================================================
-        self.main_layout: QtWidgets.QVBoxLayout
-        self.main_splitter: QtWidgets.QSplitter
-        # ======================================================================
-
-        self.main_layout.setContentsMargins(0, 0, 0, 0)
-        self.main_layout.addWidget(self.main_splitter)
-
-        ###########################################################
-        # Tabs
-        ###########################################################
-        self._create_tabs_widget()
-
-        ###########################################################
-        #  Console
-        ###########################################################
-        self._create_console()
-
-        ###########################################################
-        self.debug_mode(debug)
-        self.setup_menu()
-
-        # ##################
-
-        self.statusBar()
-
-        # ##################
-        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
-
-    def load_ui_file(self, ui_file: str) -> None:
-        qtwidgets.ui_loader.load_ui(ui_file, self)
-
-    def show_about_window(self) -> None:
-        qtwidgets.dialog.about_dialog_box(parent=self)
-
-    def show_system_info(self) -> None:
-        system_info_dialog = qtwidgets.dialog.SystemInfoDialog(self)
-        system_info_dialog.exec()
-
-    def show_help(self) -> None:
-        try:
-            pkg_metadata: metadata.PackageMetadata = \
-                metadata.metadata(speedwagon.__name__)
-            webbrowser.open_new(pkg_metadata['Home-page'])
-        except metadata.PackageNotFoundError as error:
-            self.log_manager.warning(
-                f"No help link available. Reason: {error}"
-            )
-
-    def setup_menu(self) -> None:
-        # Add menu bar
-        menu_bar = self.menuBar()
-
-        # File Menu
-        file_menu = menu_bar.addMenu("File")
-
-        # File --> Export Log
-        export_logs_button = QtGui.QAction(" &Export Log", self)
-        export_logs_button.setIcon(
-            self.style().standardIcon(
-                QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton
-            )
-        )
-
-        # pylint: disable=no-member
-        export_logs_button.triggered.connect(self.save_log)  # type: ignore
-
-        file_menu.addAction(export_logs_button)
-        file_menu.setObjectName("fileMenu")
-        file_menu.addAction(export_logs_button)
-        file_menu.setObjectName("fileMenu")
-        file_menu.addSeparator()
-
-        # File --> Exit
-        # Create Exit button
-        exit_button = QtGui.QAction(" &Exit", self)
-        exit_button.setObjectName("exitAction")
-        exit_button.triggered.connect(  # type: ignore
-            QtWidgets.QApplication.exit
-        )
-        file_menu.addAction(exit_button)
-        system_menu = menu_bar.addMenu("System")
-        system_menu.setObjectName("systemMenu")
-
-        # System --> Configuration
-        # Create a system info menu item
-        system_settings_menu_item = \
-            QtGui.QAction("Settings", self)
-        system_settings_menu_item.setObjectName('settingsAction')
-
-        system_settings_menu_item.triggered.connect(  # type: ignore
-            self.show_configuration)
-
-        system_settings_menu_item.setShortcut("Ctrl+Shift+S")
-        system_menu.addAction(system_settings_menu_item)
-
-        # System --> System Info
-        # Create a system info menu item
-        system_info_menu_item = QtGui.QAction("System Info", self)
-        system_info_menu_item.setObjectName("systemInfoAction")
-        system_info_menu_item.triggered.connect(  # type: ignore
-            self.show_system_info
-        )
-        system_menu.addAction(system_info_menu_item)
-
-        # Help Menu
-        help_menu = menu_bar.addMenu("Help")
-
-        # Help --> Help
-        # Create a Help menu item
-        help_button = QtGui.QAction(" &Help ", self)
-        help_button.triggered.connect(self.show_help)  # type: ignore
-        help_menu.addAction(help_button)
-
-        # Help --> About
-        # Create an About button
-        about_button = QtGui.QAction(" &About ", self)
-        about_button.triggered.connect(self.show_about_window)  # type: ignore
-        help_menu.addAction(about_button)
-
-    def _create_console(self) -> None:
-
-        self.console = ToolConsole(self.main_splitter)
-        self.console.setMinimumHeight(75)
-        self.console.setSizePolicy(CONSOLE_SIZE_POLICY)
-        self.main_splitter.addWidget(self.console)
-        self._log_data = io.StringIO()
-        self.log_data_handler = logging.StreamHandler(self._log_data)
-        self.log_data_handler.setFormatter(DEBUG_LOGGING_FORMAT)
-        self.log_manager.addHandler(self.log_data_handler)
-
-    def _create_tabs_widget(self) -> None:
-        self.tab_widget = ItemTabsWidget(self.main_splitter)
-        self.tab_widget.setVisible(False)
-        self._tabs: List[qtwidgets.tabs.ItemSelectionTab] = []
-        # Add the tabs widget as the first widget
-        self.tab_widget.setSizePolicy(TAB_WIDGET_SIZE_POLICY)
-        self.main_splitter.addWidget(self.tab_widget)
-        self.main_splitter.setStretchFactor(0, 0)
-        self.main_splitter.setStretchFactor(1, 2)
-
-    def debug_mode(self, debug: bool) -> None:
-        """Set debug mode on or off."""
-        super().debug_mode(debug)
-        if debug:
-            self._set_logging_level(logging.DEBUG)
-            self.console.log_handler.setFormatter(DEBUG_LOGGING_FORMAT)
-
-        else:
-            self._set_logging_level(logging.INFO)
-
-    def _set_logging_level(self, level: int) -> None:
-        self.console.log_handler.setLevel(level)
-        self.log_data_handler.setLevel(level)
 
-    def set_current_tab(self, tab_name: str) -> None:
 
-        size = self.tab_widget.tabs.count()
-        for tab in range(size):
-            tab_title = self.tab_widget.tabs.tabText(tab)
-            if tab_name == tab_title:
-                self.tab_widget.tabs.setCurrentIndex(tab)
-                return
-        self.log_manager.warning(f"Unable to set tab to {tab_name}.")
-
-    def add_tab(
-            self,
-            workflow_name: str,
-            workflows: typing.Dict[str, typing.Type[Workflow]]
-    ) -> None:
-
-        workflows_tab = qtwidgets.tabs.WorkflowsTab(
-            parent=self,
-            workflows=workflows,
-            work_manager=self.work_manager,
-            log_manager=self.log_manager
-        )
-        workflows_tab.parent = self
-        workflows_tab.workflows = workflows
-        self._tabs.append(workflows_tab)
-        self.tab_widget.add_tab(workflows_tab.tab_widget, workflow_name)
-        self.tab_widget.setVisible(True)
+class ItemTabsWidget(ItemTabsUI):
+    tabs: QtWidgets.QTabWidget
+    submit_job = QtCore.Signal(str, dict)
 
-    def show_configuration(self) -> None:
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        self.layout().addWidget(self.tabs)
+        self._model = models.TabsTreeModel()
+        self._model.modelReset.connect(self._model_reset)
+
+    def model(self):
+        return self._model
+
+    def _model_reset(self):
+        self.tabs.clear()
+        tab_count = self._model.rowCount()
+        for tab_row_id in range(tab_count):
+            tab_index = self._model.index(tab_row_id)
+            tab_name = self._model.data(tab_index)
+
+            workflows_tab = qtwidgets.tabs.WorkflowsTab3(parent=self.tabs)
+            workflows_tab.start_workflow.connect(self.submit_job)
+
+            workflow_klasses = {}
+            for workflow_row_id in range(self._model.rowCount(tab_index)):
+                workflow = self._model.data(
+                    self._model.index(workflow_row_id, parent=tab_index),
+                    role=models.WorkflowClassRole
+                )
 
-        config_dialog = qtwidgets.dialog.settings.SettingsDialog(parent=self)
+                workflow_klasses[workflow.name] = workflow
+            tab_model = tab_models.TabProxyModel()
+            tab_model.setSourceModel(self._model)
+            tab_model.set_source_tab(tab_name)
+            workflows_tab.set_model(tab_model)
 
-        if self.work_manager.settings_path is not None:
-            config_dialog.settings_location = self.work_manager.settings_path
+            self.tabs.addTab(workflows_tab, tab_name)
 
-        global_settings_tab = qtwidgets.dialog.GlobalSettingsTab()
+    def add_tab(self, tab: QtWidgets.QWidget, name: str) -> None:
+        self.tabs.addTab(tab, name)
 
-        if self.work_manager.settings_path is not None:
-            global_settings_tab.config_file = \
-                os.path.join(
-                    self.work_manager.settings_path, "config.ini")
+    def add_workflows_tab(self, name, workflows):
+        self._model.append_workflow_tab(name, workflows)
 
-            global_settings_tab.read_config_data()
+    def clear_tabs(self) -> None:
+        self._model.clear()
+        self._model_reset()
 
-        config_dialog.add_tab(global_settings_tab, "Global Settings")
+    def count(self) -> int:
+        return self.tabs.count()
 
-        # pylint: disable=no-member
-        config_dialog.accepted.connect(  # type: ignore
-            global_settings_tab.on_okay
+    @property
+    def current_tab(self) -> Optional[qtwidgets.tabs.WorkflowsTab3]:
+        return typing.cast(
+            Optional[qtwidgets.tabs.WorkflowsTab3],
+            self.tabs.currentWidget()
         )
 
-        tabs_tab = qtwidgets.dialog.TabsConfigurationTab()
-
-        if self.work_manager.settings_path is not None:
-            tabs_tab.settings_location = \
-                os.path.join(self.work_manager.settings_path, "tabs.yml")
-            tabs_tab.load()
-
-        config_dialog.add_tab(tabs_tab, "Tabs")
-        config_dialog.accepted.connect(tabs_tab.on_okay)  # type: ignore
-
-        config_dialog.exec()
-
-    def start_workflow(self) -> None:
-        num_selected = self._workflow_selector_view.selectedIndexes()
-        if len(num_selected) != 1:
-            print(
-                "`Invali`d number of selected Indexes. "
-                f"Expected 1. Found {num_selected}"
-            )
-
-    def save_log(self) -> None:
-        data = self._log_data.getvalue()
 
-        epoch_in_minutes = int(time.time() / 60)
-        log_file_name, _ = \
-            QtWidgets.QFileDialog.getSaveFileName(
-                self,
-                "Export Log",
-                f"speedwagon_log_{epoch_in_minutes}.txt",
-                "Text Files (*.txt)")
+class MainWindow3UI(QtWidgets.QMainWindow):
+    main_splitter: QtWidgets.QSplitter
+    main_layout: QtWidgets.QVBoxLayout
+    console: ToolConsole
+    tab_widget: ItemTabsWidget
+    menu_bar: QtWidgets.QMenuBar
+    action_exit: QtGui.QAction
+    action_export_logs: QtGui.QAction
+    action_export_job: QtGui.QAction
+    action_import_job: QtGui.QAction
+    action_system_info_requested: QtGui.QAction
+    action_open_application_preferences: QtGui.QAction
+    action_help_requested: QtGui.QAction
+    action_about: QtGui.QAction
 
-        if not log_file_name:
-            return
-        with open(log_file_name, "w", encoding="utf-8") as file_handle:
-            file_handle.write(data)
-
-        self.log_manager.info(f"Saved log to {log_file_name}")
-
-
-MainWindow = MainWindow1
-
-
-class MainWindow2UI(QtWidgets.QMainWindow):
-
-    def __init__(
-            self,
-            parent: typing.Optional[QtWidgets.QWidget] = None
-    ) -> None:
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
         super().__init__(parent)
         with as_file(
-                resources.files(qtwidgets.ui).joinpath("main_window2.ui")
+                resources.files(qtwidgets.ui).joinpath("main_window3.ui")
         ) as ui_file:
             qtwidgets.ui_loader.load_ui(str(ui_file), self)
 
-        # ======================================================================
-        # Type hints
-        # ======================================================================
-        self.main_layout: QtWidgets.QVBoxLayout
-        self.main_splitter: QtWidgets.QSplitter
-        # ======================================================================
-
 
-class MainWindow2(MainWindow2UI):
+class MainWindow3(MainWindow3UI):
     submit_job = QtCore.Signal(str, dict)
-    configuration_requested = QtCore.Signal(QtWidgets.QWidget)
-    system_info_requested = QtCore.Signal(QtWidgets.QWidget)
-    help_requested = QtCore.Signal()
-    save_logs_requested = QtCore.Signal(QtWidgets.QWidget)
     export_job_config = QtCore.Signal(str, dict, QtWidgets.QWidget)
-    import_job_config = QtCore.Signal(QtWidgets.QWidget)
 
-    def __init__(
-            self,
-            job_manager: "speedwagon.runner_strategies.BackgroundJobManager",
-            settings: typing.Optional[
-                typing.Dict[str, typing.Union[str, bool]]
-            ] = None
-            ) -> None:
-        super().__init__()
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
         self.logger = logging.getLogger(self.__class__.__name__)
-        self.job_manager = job_manager
-        self.user_settings = settings or {}
+        self.job_manager: Optional[
+            speedwagon.runner_strategies.BackgroundJobManager
+        ] = None
 
-        self.main_layout.setContentsMargins(0, 0, 0, 0)
-        self.main_layout.addWidget(self.main_splitter)
+        self.config_strategy: speedwagon.config.AbsConfigSettings = \
+            StandardConfig()
 
-        ###########################################################
-        # Tabs
-        ###########################################################
-        self._create_tabs_widget()
+        self.main_layout.setContentsMargins(0, 0, 0, 0)
 
         ###########################################################
         #  Console
         ###########################################################
-        self._create_console()
-        self.setup_menu()
+        self.console.attach_logger(self.logger)
+        ###########################################################
+        self.action_export_logs.setIcon(
+            self.style().standardIcon(
+                QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton
+            )
+        )
         ###########################################################
+        self.action_export_job.triggered.connect(self._export_job_config)
+        self.tab_widget.submit_job.connect(self.submit_job)
+        self.submit_job.connect(lambda *args: print("got it"))
+
+    def update_settings(self) -> None:
+        settings = self._get_settings()
+        global_settings = settings.get("GLOBAL")
+        if global_settings:
+            debug_mode = global_settings.get("debug", False)
+        else:
+            debug_mode = False
 
-    def set_current_tab(self, tab_name: str) -> None:
-        tab_index = self.locate_tab_index_by_name(tab_name)
-        if tab_index is None:
-            raise IndexError(f"No tab named {tab_name}")
-        self.tab_widget.tabs.setCurrentIndex(tab_index)
+        if debug_mode is True:
+            if self.console.log_handler.level != logging.DEBUG:
+                self.console.log_handler.setLevel(logging.DEBUG)
+                self.console.add_message(
+                    "Putting Speedwagon into Debug mode. "
+                )
+            self.setWindowTitle("Speedwagon (DEBUG)")
+        else:
+            if self.console.log_handler.level != logging.INFO:
+                self.console.log_handler.setLevel(logging.INFO)
+                self.console.add_message(
+                    "Putting Speedwagon into Normal mode. "
+                )
+            self.setWindowTitle("Speedwagon")
+
+    def _export_job_config(self) -> None:
+        if self.tab_widget.current_tab is not None:
+            self.export_job_config.emit(
+                self.tab_widget.current_tab.workspace.name,
+                self.tab_widget.current_tab.workspace.configuration,
+                self
+            )
 
-    def locate_tab_index_by_name(self, name) -> typing.Optional[int]:
+    def locate_tab_index_by_name(self, name: str) -> typing.Optional[int]:
         for index in range(self.tab_widget.tabs.count()):
             if self.tab_widget.tabs.tabText(index) == name:
                 return index
         return None
 
+    def clear_tabs(self) -> None:
+        self.tab_widget.clear_tabs()
+
+    def add_tab(
+            self,
+            tab_name: str,
+            workflows: typing.Dict[
+                str,
+                typing.Type[Workflow]
+            ]
+    ) -> None:
+        self.tab_widget.add_workflows_tab(tab_name, workflows.values())
+
     def set_active_workflow(self, workflow_name: str) -> None:
         tab_index = self.locate_tab_index_by_name("All")
         if tab_index is None:
             raise AssertionError("Missing All tab")
-        all_tab = self._tabs[tab_index]
-        for i in range(all_tab.item_selector_view.model().rowCount()):
-            workflow_index = all_tab.item_selector_view.model().index(i, 0)
-            name = workflow_index.data()
-            if name == workflow_name:
-                all_tab.item_selector_view.setCurrentIndex(workflow_index)
+        self.tab_widget.tabs.setCurrentIndex(tab_index)
+        current_tab = self.tab_widget.current_tab
+        if current_tab:
+            current_tab.set_current_workflow(workflow_name)
 
     def set_current_workflow_settings(
             self,
             data: typing.Dict[str, typing.Any]
-    ):
+    ) -> None:
         tab_index = self.locate_tab_index_by_name("All")
         if tab_index is None:
             raise AssertionError("Missing All tab")
-        all_tab = self._tabs[tab_index]
-        current_tab_index = self.tab_widget.tabs.currentIndex()
-        item_selected_index = \
-            self._tabs[
-                current_tab_index
-            ].item_selector_view.selectedIndexes()[0]
-        current_workflow = typing.cast(
-            typing.Type[Workflow],
-            self._tabs[
-                current_tab_index
-            ].item_selection_model.data(
-                item_selected_index,
-                role=typing.cast(int, QtCore.Qt.ItemDataRole.UserRole)
-            )
-        )
-        workflow_inst = current_workflow(self.user_settings)
-        workflow_inst.global_settings = self.user_settings
-        options = workflow_inst.get_user_options()
-        load_job_settings_model(
-            data,
-            all_tab.workspace_widget.settings_form,
-            options
-        )
-        self._tabs[current_tab_index].options_model = \
-            all_tab.workspace_widget.settings_form.model
+        current_tab = self.tab_widget.current_tab
+        if current_tab:
+            current_tab.set_current_workflow_settings(data)
 
-    def close(self) -> bool:
-        self.console.close()
-        return super().close()
-
-    def closeEvent(  # pylint: disable=C0103
-            self,
-            event: QtGui.QCloseEvent
-    ) -> None:
-        self.console.close()
+    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
+        self.console.detach_logger()
         super().closeEvent(event)
 
-    def setup_menu(self) -> None:
-        builder = MainWindowMenuBuilder(parent=self)
-        builder.exit_function = lambda: self.close()
-
-        builder.show_system_info_signal = \
-            lambda: self.system_info_requested.emit(self)
-
-        builder.show_configuration_signal = \
-            lambda: self.configuration_requested.emit(self)
-
-        builder.save_log_function = self.save_log
-
-        builder.export_signal = lambda: self.export_job_config.emit(
-            self.get_current_workflow_name(),
-            self.get_current_job_settings(),
-            self
-        )
-
-        builder.import_signal = lambda: self.import_job_config.emit(self)
-
-        builder.add_help = True
-        builder.build()
-
-    def get_current_workflow_name(self) -> typing.Optional[str]:
-        current_tab_index = self.tab_widget.tabs.currentIndex()
-
-        item_selected_index = \
-            self._tabs[
-                current_tab_index
-            ].item_selector_view.selectedIndexes()[0]
-
-        current_workflow = typing.cast(
-                Workflow,
-                self._tabs[
-                    current_tab_index
-                ].item_selection_model.data(
-                    item_selected_index,
-                    role=typing.cast(int, QtCore.Qt.ItemDataRole.UserRole)
-                )
-            )
-        return current_workflow.name
-
-    def get_current_job_settings(self) -> typing.Dict[str, typing.Any]:
-        current_tab = self._tabs[self.tab_widget.tabs.currentIndex()]
-        if current_tab is None:
-            raise IndexError("Unable to locate the current tab")
-        if current_tab.options_model is None:
-            raise ValueError("Current tab has no option model")
-        current_tab.workspace_widget.settings_form.update_model()
-        return current_tab.options_model.get()
-
-    def add_tab(
-            self,
-            tab_name: str,
-            workflows: typing.Mapping[
-                str,
-                typing.Type[Workflow]
-            ]
-    ) -> None:
-
-        workflows_tab = qtwidgets.tabs.WorkflowsTab2(
-            parent=self,
-            workflows=workflows,
-        )
-        workflows_tab.tab_name = tab_name
-        workflows_tab.signals.start_workflow.connect(self._start_workflow)
-
-        workflows_tab.parent = self
-        self._tabs.append(workflows_tab)
-        self.tab_widget.add_tab(workflows_tab.tab_widget, tab_name)
-        self.tab_widget.setVisible(True)
-
-    def _start_workflow(self,
-                        workflow: str,
-                        options: typing.Dict[str, typing.Any]) -> None:
-        self.submit_job.emit(workflow, options)
-
-    def show_about_window(self) -> None:
-        qtwidgets.dialog.about_dialog_box(parent=self)
-
-    def save_log(self) -> None:
-        self.save_logs_requested.emit(self)
-
-    def _create_tabs_widget(self) -> None:
-        self.tab_widget = ItemTabsWidget(self.main_splitter)
-        self.tab_widget.setVisible(False)
-        self._tabs: List[qtwidgets.tabs.ItemSelectionTab] = []
-
-        # Add the tabs widget as the first widget
-        # self.tab_widget.setSizePolicy(TAB_WIDGET_SIZE_POLICY)
-        # self.main_splitter.addWidget(self.tab_widget)
-        # self.main_splitter.setStretchFactor(0, 1)
-        # self.main_splitter.setStretchFactor(1, 0)
-
-    def _create_console(self) -> None:
-
-        self.console = ToolConsole(self.main_splitter)
-        self.console.log_formatter.verbose = \
-            typing.cast(
-                bool,
-                self.user_settings.get('debug', False)
-            )
-
-        self.console.setMinimumHeight(75)
-        self.console.setSizePolicy(CONSOLE_SIZE_POLICY)
-        self.main_splitter.addWidget(self.console)
-        self.console.attach_logger(self.logger)
+    def _get_settings(self) -> FullSettingsData:
+        return self.config_strategy.settings()
 
 
 def set_app_display_metadata(app: QtWidgets.QApplication) -> None:
@@ -851,25 +321,25 @@ def set_app_display_metadata(app: QtWidgets.QApplication) -> None:
             resources.files("speedwagon").joinpath("favicon.ico")
     ) as favicon_file:
         app.setWindowIcon(QtGui.QIcon(str(favicon_file)))
-    try:
+    with contextlib.suppress(metadata.PackageNotFoundError):
         app.setApplicationVersion(metadata.version(__package__))
-    except metadata.PackageNotFoundError:
-        pass
     app.setApplicationDisplayName(f"{speedwagon.__name__.title()}")
     QtWidgets.QApplication.processEvents()
 
 
 def load_job_settings_model(
-        data: Dict[str, Union[str, bool]],
+        data: Dict[str, widgets.UserDataType],
         settings_widget: widgets.DynamicForm,
         workflow_options: List[AbsOutputOptionDataType]
-):
-    model = models.ToolOptionsModel4(workflow_options)
+) -> None:
+    model = option_models.ToolOptionsModel4(workflow_options)
     for key, value in data.items():
         for i in range(model.rowCount()):
             index = model.index(i)
-            option_data: AbsOutputOptionDataType = \
-                model.data(index, models.ToolOptionsModel4.DataRole)
+            option_data = typing.cast(
+                AbsOutputOptionDataType,
+                model.data(index, option_models.ToolOptionsModel4.DataRole)
+            )
 
             if option_data.label == key:
                 model.setData(index, value, QtCore.Qt.ItemDataRole.EditRole)
diff --git a/speedwagon/frontend/qtwidgets/gui_startup.py b/speedwagon/frontend/qtwidgets/gui_startup.py
index 102ae79d4..3e3048d6d 100644
--- a/speedwagon/frontend/qtwidgets/gui_startup.py
+++ b/speedwagon/frontend/qtwidgets/gui_startup.py
@@ -6,12 +6,13 @@
 import json
 import logging
 import os
-import queue
 import sys
 import threading
 import time
+import types
 import typing
-from typing import Dict, Union, Optional, cast, List, Type
+from typing import Dict, Optional, cast, List, Type, Callable
+import traceback as tb
 import webbrowser
 try:  # pragma: no cover
     from importlib import metadata
@@ -20,20 +21,22 @@
 from PySide6 import QtWidgets
 
 import speedwagon
+from speedwagon.workflow import initialize_workflows
 from speedwagon import config
 from . import user_interaction
 from . import dialog
+from .dialog.settings import EntrypointsPluginModelLoader
 from . import runners
 
 if typing.TYPE_CHECKING:
-    from typing import Tuple
     from speedwagon import runner_strategies
     from speedwagon.frontend.qtwidgets import gui
     from speedwagon.frontend.qtwidgets.dialog import dialogs
     from speedwagon.job import AbsWorkflow, AbsJobConfigSerializationStrategy
+    from speedwagon.config import FullSettingsData
 
 
-class AbsGuiStarter(speedwagon.startup.AbsStarter):
+class AbsGuiStarter(speedwagon.startup.AbsStarter, abc.ABC):
 
     def __init__(self, app) -> None:
         super().__init__()
@@ -47,19 +50,244 @@ def start_gui(self, app: Optional[QtWidgets.QApplication] = None) -> int:
         """Run the gui application."""
 
 
+def save_workflow_config(
+        workflow_name,
+        data,
+        parent: QtWidgets.QWidget,
+        dialog_box: typing.Optional[QtWidgets.QFileDialog] = None,
+        serialization_strategy: typing.Optional[
+            speedwagon.job.AbsJobConfigSerializationStrategy
+        ] = None
+) -> None:
+    serialization_strategy = \
+        serialization_strategy or speedwagon.job.ConfigJSONSerialize()
+
+    dialog_box = dialog_box or QtWidgets.QFileDialog()
+    export_file_name, _ = dialog_box.getSaveFileName(
+        parent,
+        "Export Job Configuration",
+        f"{workflow_name}.json",
+        "Job Configuration JSON (*.json)"
+    )
+
+    if export_file_name:
+        serialization_strategy.file_name = export_file_name
+        serialization_strategy.save(workflow_name, data)
+
+
+class AbsResolveSettingsStrategy(abc.ABC):  # pylint: disable=R0903
+
+    @abc.abstractmethod
+    def get_settings(self) -> FullSettingsData:
+        """Get full settings"""
+
+
+class ResolveSettings(AbsResolveSettingsStrategy):  # pylint: disable=R0903
+    def get_settings(self) -> FullSettingsData:
+        config_strategy = config.StandardConfigFileLocator()
+        manager = config.IniConfigManager(config_strategy.get_config_file())
+        return manager.data()
+
+
+class AbsStartupTask(abc.ABC):
+    @abc.abstractmethod
+    def run(self) -> None:
+        """Run a startup task."""
+
+    @abc.abstractmethod
+    def description(self) -> str:
+        """Get human-readable information about current task."""
+
+
+class EnsureGlobalConfigFiles(AbsStartupTask):
+
+    def __init__(self, logger) -> None:
+        super().__init__()
+        self.logger = logger
+
+    def run(self) -> None:
+        config.ensure_settings_files(logger=self.logger)
+
+    def description(self) -> str:
+        return "Ensuring global settings files are available and creating " \
+               "defaults where missing."
+
+
+class EnsureWorkflowConfigFiles(AbsStartupTask):
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.config_file_location_strategy = \
+            speedwagon.config.StandardConfigFileLocator()
+
+    def run(self) -> None:
+        for name, workflow_klass in \
+                speedwagon.job.available_workflows().items():
+            try:
+                workflow = workflow_klass()
+                self.ensure_workflow_config(
+                    workflow, workflow.workflow_options()
+                )
+            except speedwagon.exceptions.MissingConfiguration:
+                print(f"[{name}] has missing config")
+
+    def get_config_file(self):
+        return os.path.join(
+            self.config_file_location_strategy.get_app_data_dir(),
+            speedwagon.config.WORKFLOWS_SETTINGS_YML_FILE_NAME
+        )
+
+    def ensure_workflow_config(
+            self,
+            workflow: speedwagon.Workflow,
+            config_options: List[speedwagon.workflow.AbsOutputOptionDataType]
+    ):
+        yaml_file = self.get_config_file()
+
+        getter_strategy = \
+            speedwagon.config.WorkflowSettingsYAMLResolver(yaml_file)
+
+        setter_strategy = \
+            speedwagon.config.WorkflowSettingsYamlExporter(yaml_file)
+
+        manager = speedwagon.config.WorkflowSettingsManager(
+            getter_strategy=getter_strategy,
+            setter_strategy=setter_strategy
+        )
+        workflow_settings: speedwagon.config.SettingsData = {}
+        existing_options = manager.get_workflow_settings(workflow)
+        for option in config_options:
+            name = option.setting_name or option.label
+            workflow_settings[name] = existing_options.get(name)
+            if (
+                    workflow_settings[name] is None and
+                    option.default_value is not None
+            ):
+                workflow_settings[name] = option.default_value
+
+        if workflow_settings:
+            manager.save_workflow_settings(workflow, workflow_settings)
+
+    def description(self) -> str:
+        return "Ensuring workflow settings file are available and creating " \
+               "defaults where missing."
+
+
+def _setup_config_tab(
+        saver: dialog.settings.MultiSaver
+) -> dialog.settings.TabsConfigurationTab:
+    config_strategy = config.StandardConfigFileLocator()
+    tabs_config = dialog.settings.TabsConfigurationTab()
+    tabs_config.editor.load_data()
+    saver.config_savers.append(
+        dialog.settings.ConfigFileSaver(
+            dialog.settings.SettingsTabSaveStrategy(
+                tabs_config,
+                lambda widget: config.TabsYamlWriter().serialize(
+                    filter(
+                        lambda tab: tab.tab_name != "All",
+                        widget.get_data().get('tab_information', []),
+                    )
+                )
+            ),
+            config_strategy.get_tabs_file()
+        )
+    )
+    return tabs_config
+
+
+def _setup_workflow_settings_tab(
+        saver: dialog.settings.MultiSaver
+) -> dialog.settings.ConfigWorkflowSettingsTab:
+    workflow_settings_tab = dialog.settings.ConfigWorkflowSettingsTab()
+    config_strategy = config.StandardConfigFileLocator()
+    saver.config_savers.append(
+        dialog.settings.ConfigFileSaver(
+            dialog.settings.SettingsTabSaveStrategy(
+                workflow_settings_tab,
+                lambda widget: speedwagon.config.SettingsYamlSerializer
+                .serialize_structure_to_yaml(
+                    {
+                        workflow_name:
+                            speedwagon.config.SettingsYamlSerializer
+                            .structure_workflow_data(value)
+                        for (workflow_name, value) in
+                        widget.get_data()['workflow settings'].items()
+                    }
+                )
+            ),
+            os.path.join(
+                config_strategy.get_app_data_dir(),
+                speedwagon.config.WORKFLOWS_SETTINGS_YML_FILE_NAME
+            )
+        )
+    )
+    # Only add workflows to the configuration if it has something to
+    # configure. Otherwise, this gets overly cluttered.
+    workflow_settings_tab.set_workflows(
+        filter(
+            lambda workflow: len(workflow.workflow_options()) != 0,
+            initialize_workflows()
+        )
+    )
+    return workflow_settings_tab
+
+
+def _setup_global_settings_tab(
+        saver: dialog.settings.MultiSaver
+) -> dialog.settings.GlobalSettingsTab:
+    config_strategy = config.StandardConfigFileLocator()
+    global_settings_tab = dialog.settings.GlobalSettingsTab()
+    saver.config_savers.append(
+        dialog.settings.ConfigFileSaver(
+            dialog.settings.SettingsTabSaveStrategy(
+                global_settings_tab,
+                lambda widget: config.IniConfigSaver().serialize(
+                    {
+                        'GLOBAL': typing.cast(
+                            speedwagon.config.SettingsData, widget.get_data()
+                        )
+                    }
+                )
+            ),
+            config_strategy.get_config_file()
+        )
+    )
+    global_settings_tab.load_ini_file(config_strategy.get_config_file())
+    return global_settings_tab
+
+
+def _setup_plugins_tab(
+        _: dialog.settings.MultiSaver
+) -> dialog.settings.PluginsTab:
+    config_strategy = config.StandardConfigFileLocator()
+    plugins_tab = dialog.settings.PluginsTab()
+    plugins_tab.load(config_strategy.get_config_file())
+    return plugins_tab
+
+
 class StartQtThreaded(AbsGuiStarter):
 
     def __init__(self, app: Optional[QtWidgets.QApplication] = None) -> None:
-        self.startup_settings: Dict[str, Union[str, bool]] = {'debug': False}
-
-        self.windows: Optional[gui.MainWindow2] = None
+        super().__init__(app)
+        self.settings_resolver: AbsResolveSettingsStrategy = ResolveSettings()
+        self.startup_settings: FullSettingsData = {
+            "GLOBAL": {
+                'debug': False
+            }
+        }
+
+        self.windows: Optional[gui.MainWindow3] = None
         self.logger = logging.getLogger()
 
         formatter = \
             logging.Formatter('%(asctime)-15s %(threadName)s %(message)s')
 
         self.platform_settings = config.get_platform_settings()
-        self.app = app or QtWidgets.QApplication(sys.argv)
+        self.app: QtWidgets.QApplication = (
+            app
+            or QtWidgets.QApplication(sys.argv)
+        )
         self._log_data = io.StringIO()
 
         log_data_handler = logging.StreamHandler(self._log_data)
@@ -69,13 +297,12 @@ def __init__(self, app: Optional[QtWidgets.QApplication] = None) -> None:
         self.logger.addHandler(log_data_handler)
         self.logger.setLevel(logging.DEBUG)
 
-        self.load_settings()
         speedwagon.frontend.qtwidgets.gui.set_app_display_metadata(self.app)
         self._request_window = user_interaction.QtRequestMoreInfo(self.windows)
 
     @staticmethod
     def import_workflow_config(
-            parent: gui.MainWindow2,
+            parent: gui.MainWindow3,
             dialog_box: typing.Optional[QtWidgets.QFileDialog] = None,
             serialization_strategy: typing.Optional[
                 AbsJobConfigSerializationStrategy] = None
@@ -98,54 +325,9 @@ def import_workflow_config(
         serialization_strategy.file_name = load_file
         workflow_name, data = serialization_strategy.load()
         parent.logger.debug(f"Loading {workflow_name}")
-        parent.set_current_tab("All")
         parent.set_active_workflow(workflow_name)
         parent.set_current_workflow_settings(data)
 
-    @staticmethod
-    def save_workflow_config(
-            workflow_name,
-            data,
-            parent: QtWidgets.QWidget,
-            dialog_box: typing.Optional[QtWidgets.QFileDialog] = None,
-            serialization_strategy: typing.Optional[
-                speedwagon.job.AbsJobConfigSerializationStrategy
-            ] = None
-    ) -> None:
-        serialization_strategy = \
-            serialization_strategy or speedwagon.job.ConfigJSONSerialize()
-
-        dialog_box = dialog_box or QtWidgets.QFileDialog()
-        export_file_name, _ = dialog_box.getSaveFileName(
-                parent,
-                "Export Job Configuration",
-                f"{workflow_name}.json",
-                "Job Configuration JSON (*.json)"
-        )
-
-        if export_file_name:
-            serialization_strategy.file_name = export_file_name
-            serialization_strategy.save(workflow_name, data)
-
-    def load_settings(self) -> None:
-        self.user_data_dir = typing.cast(
-            str, self.platform_settings.get("user_data_directory")
-        )
-
-        self.config_file = os.path.join(
-            self.platform_settings.get_app_data_directory(),
-            speedwagon.startup.CONFIG_INI_FILE_NAME
-        )
-
-        self.app_data_dir = typing.cast(
-            str, self.platform_settings["app_data_directory"]
-        )
-
-        self.tabs_file = os.path.join(
-            self.platform_settings.get_app_data_directory(),
-            speedwagon.startup.TABS_YML_FILE_NAME
-        )
-
     def _load_help(self) -> None:
         try:
             pkg_metadata: metadata.PackageMetadata = \
@@ -156,52 +338,35 @@ def _load_help(self) -> None:
                 "No help link available. Reason: %s", error)
 
     def ensure_settings_files(self) -> None:
-        config.ensure_settings_files(self, logger=self.logger)
-
-    @staticmethod
-    def read_settings_file(settings_file: str) -> Dict[str, Union[str, bool]]:
-
-        with config.ConfigManager(settings_file) as current_config:
-            return current_config.global_settings
-
-    def resolve_settings(
-            self,
-            resolution_order: Optional[
-                List[config.AbsSetting]
-            ] = None,
-            loader: Optional[config.ConfigLoader] = None
-    ) -> Dict[str, Union[str, bool]]:
-
-        loader = loader or config.ConfigLoader(self.config_file)
+        config.ensure_settings_files(logger=self.logger)
 
+    def resolve_settings(self) -> FullSettingsData:
+        settings = self.settings_resolver.get_settings()
         self.platform_settings._data.update(
-            loader.read_settings_file(self.config_file)
+            settings.get("GLOBAL", {})
         )
-
-        loader.logger = self.logger
-        if resolution_order:
-            loader.resolution_strategy_order = resolution_order
-
-        results = loader.get_settings()
-
-        self.startup_settings = results
+        self.startup_settings = settings
         return self.startup_settings
 
     def initialize(self) -> None:
-        self.ensure_settings_files()
+        startup_tasks: List[AbsStartupTask] = [
+            EnsureGlobalConfigFiles(self.logger),
+            EnsureWorkflowConfigFiles()
+        ]
+        for task in startup_tasks:
+            task.run()
+
+        # self.ensure_settings_files()
         self.startup_settings = self.resolve_settings()
 
-    def _load_workflows(
-            self,
-            application: gui.MainWindow2
-    ) -> None:
-        tabs_file = os.path.join(
-            self.platform_settings.get_app_data_directory(),
-            speedwagon.startup.TABS_YML_FILE_NAME
-        )
+    def load_workflows(self) -> None:
+        if self.windows is None:
+            return
+        config_strategy = config.StandardConfigFileLocator()
 
         self.logger.debug("Loading Workflows")
         loading_workflows_stream = io.StringIO()
+        self.windows.clear_tabs()
         with contextlib.redirect_stderr(loading_workflows_stream):
             all_workflows = speedwagon.job.available_workflows()
 
@@ -211,14 +376,18 @@ def _load_workflows(
                 f"Unable to load workflow '{workflow_name}'. Reason: {error}"
 
             self.logger.error(error_message)
-            application.console.add_message(error_message)
+            self.windows.console.add_message(error_message)
             del all_workflows[workflow_name]
 
         # Load every user configured tab
-        self.load_custom_tabs(application, tabs_file, all_workflows)
+        self.load_custom_tabs(
+            self.windows,
+            config_strategy.get_tabs_file(),
+            all_workflows
+        )
 
         # All Workflows tab
-        self.load_all_workflows_tab(application, all_workflows)
+        self.load_all_workflows_tab(self.windows, all_workflows)
 
         workflow_errors_msg = loading_workflows_stream.getvalue().strip()
         if workflow_errors_msg:
@@ -227,9 +396,10 @@ def _load_workflows(
 
     def load_all_workflows_tab(
             self,
-            application: gui.MainWindow2,
+            application: gui.MainWindow3,
             loaded_workflows: typing.Dict[str, Type[speedwagon.job.Workflow]]
     ) -> None:
+        print("Loading Tab All")
         self.logger.debug("Loading Tab All")
         application.add_tab(
             "All",
@@ -240,10 +410,11 @@ def load_all_workflows_tab(
 
     def load_custom_tabs(
             self,
-            main_window: gui.MainWindow2,
+            main_window: gui.MainWindow3,
             tabs_file: str,
             loaded_workflows: typing.Dict[str, Type[speedwagon.job.Workflow]]
     ) -> None:
+
         tabs_file_size = os.path.getsize(tabs_file)
         if tabs_file_size > 0:
             try:
@@ -254,7 +425,7 @@ def load_custom_tabs(
                         ):
                     main_window.add_tab(tab_name, collections.OrderedDict(
                         sorted(extra_tab.items())))
-            except speedwagon.startup.FileFormatError as error:
+            except speedwagon.exceptions.FileFormatError as error:
                 self.logger.warning(
                     "Unable to load custom tabs from %s. Reason: %s",
                     tabs_file,
@@ -293,76 +464,121 @@ def request_system_info(
     ) -> None:
         dialog.dialogs.SystemInfoDialog(parent).exec()
 
-    @staticmethod
-    def request_settings(parent: Optional[QtWidgets.QWidget] = None) -> None:
-        platform_settings = config.get_platform_settings()
-        settings_path = platform_settings.get_app_data_directory()
+    def request_settings(
+            self,
+            parent: Optional[QtWidgets.QWidget] = None
+    ) -> None:
 
-        dialog_builder = dialog.settings.SettingsBuilder(parent=parent)
+        class TabData(typing.NamedTuple):
+            name: str
+            setup_function: Callable[
+                [dialog.settings.MultiSaver], dialog.settings.SettingsTab
+            ]
+            active: bool
 
-        dialog_builder.add_open_settings(
-            platform_settings.get_app_data_directory()
-        )
+        def are_there_any_plugins() -> bool:
+            return len(EntrypointsPluginModelLoader.plugin_entry_points()) > 0
 
-        dialog_builder.add_global_settings(
-            os.path.join(
-                settings_path,
-                speedwagon.startup.CONFIG_INI_FILE_NAME
-            )
-        )
+        tabs: List[TabData] = [
+            TabData("Workflow Settings", _setup_workflow_settings_tab, True),
+            TabData("Global Settings", _setup_global_settings_tab, True),
+            TabData("Tabs", _setup_config_tab, True),
+            TabData("Plugins", _setup_plugins_tab, are_there_any_plugins()),
+        ]
+        config_strategy = config.StandardConfigFileLocator()
 
-        dialog_builder.add_tabs_setting(
-            os.path.join(settings_path, speedwagon.startup.TABS_YML_FILE_NAME)
-        )
+        dialog_builder = dialog.settings.SettingsBuilder2(parent=parent)
+        dialog_builder.app_data_dir = config_strategy.get_app_data_dir()
 
-        config_dialog = dialog_builder.build()
-        config_dialog.exec_()
+        saver = dialog.settings.MultiSaver(parent=parent)
+
+        for tab in tabs:
+            if tab.active is True:
+                dialog_builder.add_tab(tab.name, tab.setup_function(saver))
+
+        def success(_) -> None:
+            if self.windows:
+                self.windows.clear_tabs()
+                self.windows.update_settings()
+            self.load_workflows()
+
+        saver.add_success_call_back(success)
+        dialog_builder.set_saver_strategy(saver)
+        config_dialog: dialog.settings.SettingsDialog = dialog_builder.build()
+
+        config_dialog.exec()
 
     def start_gui(self, app: Optional[QtWidgets.QApplication] = None) -> int:
 
-        with speedwagon.runner_strategies.BackgroundJobManager() \
-                as job_manager:
-            job_manager.global_settings = self.startup_settings
-            self.windows = speedwagon.frontend.qtwidgets.gui.MainWindow2(
-                job_manager=job_manager,
-                settings=self.startup_settings
-            )
+        original_hook = sys.excepthook
 
-            if self.windows is None:
-                return 1
+        # without this, unhandled exceptions won't close the application
+        # because QT/PySide keeps the GUI open.
+        try:
+            sys.excepthook = \
+                lambda cls, exception, traceback: gui_exceptions_hook(
+                    cls,
+                    exception,
+                    traceback,
+                    self.app,
+                    self.windows
+                )
+            with speedwagon.runner_strategies.BackgroundJobManager() \
+                    as job_manager:
+                job_manager.global_settings = \
+                    self.startup_settings.get("GLOBAL", {})
+
+                self.windows = self.build_main_window(job_manager)
+                self._request_window = \
+                    user_interaction.QtRequestMoreInfo(self.windows)
+                self.load_workflows()
+                self.windows.update_settings()
+                self.windows.show()
+                return self.app.exec()
+        finally:
+            sys.excepthook = original_hook
 
-            self.windows.console.attach_logger(self.logger)
-            self.windows.configuration_requested.connect(
-                self.request_settings
-            )
+    def build_main_window(
+            self,
+            job_manager
+    ) -> speedwagon.frontend.qtwidgets.gui.MainWindow3:
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        window.console.attach_logger(self.logger)
 
-            self.windows.save_logs_requested.connect(self.save_log)
+        window.action_export_logs.triggered.connect(
+            lambda: self.save_log(window)
+        )
 
-            self.windows.export_job_config.connect(
-                self.save_workflow_config
-            )
+        window.export_job_config.connect(save_workflow_config)
 
-            self.windows.import_job_config.connect(
-                self.import_workflow_config
-            )
+        window.action_import_job.triggered.connect(
+            lambda: self.import_workflow_config(window)
+        )
 
-            self.windows.system_info_requested.connect(
-                self.request_system_info
-            )
-            self.windows.help_requested.connect(self._load_help)
-            self.windows.submit_job.connect(
-                lambda workflow_name, options:
-                self.submit_job(
-                    job_manager,
-                    workflow_name,
-                    options,
-                    main_app=self.windows,
-                )
-            )
+        window.action_system_info_requested.triggered.connect(
+            lambda: self.request_system_info(window)
+        )
+
+        window.action_open_application_preferences.triggered.connect(
+            lambda: self.request_settings(window)
+        )
 
-            self._load_workflows(self.windows)
-            self.windows.show()
-            return self.app.exec_()
+        window.action_help_requested.triggered.connect(self._load_help)
+
+        window.action_about.triggered.connect(
+            lambda: dialog.about_dialog_box(window)
+        )
+
+        window.submit_job.connect(
+            lambda workflow_name, options:
+            self.submit_job(
+                job_manager,
+                workflow_name,
+                options,
+                main_app=self.windows,
+            )
+        )
+        return window
 
     @staticmethod
     def abort_job(
@@ -399,7 +615,7 @@ def submit_job(
             workflow_name: str,
             options: Dict[str, typing.Any],
             main_app: typing.Optional[
-                gui.MainWindow2
+                gui.MainWindow3
             ] = None,
     ) -> None:
 
@@ -410,10 +626,11 @@ def submit_job(
                 raise ValueError(f"Unknown workflow: '{workflow_name}'")
             workflow_class.validate_user_options(**options)
         except ValueError as user_option_error:
-            self.report_exception(
-                parent=main_app,
+            report_exception_dialog(
                 exc=user_option_error,
-                dialog_box_title="Invalid User Options"
+                parent=main_app,
+                dialog_box_title="Invalid User Options",
+                is_fatal=False,
             )
             return
 
@@ -450,15 +667,6 @@ def submit_job(
         )
         threaded_events.started.set()
 
-    def report_exception(
-            self,
-            exc: BaseException,
-            parent: typing.Optional[QtWidgets.QWidget] = None,
-            dialog_box_title: Optional[str] = None,
-    ) -> None:
-        self.logger.error(str(exc))
-        report_exception_dialog(exc, dialog_box_title, parent)
-
     def _find_invalid(
             self,
             workflows: typing.Dict[str, typing.Type[speedwagon.job.Workflow]]
@@ -467,7 +675,9 @@ def _find_invalid(
     ]:
         for title, workflow in workflows.copy().items():
             try:
-                workflow(global_settings=self.startup_settings)
+                workflow(
+                    global_settings=self.startup_settings.get('GLOBAL', {})
+                )
             except (
                     speedwagon.exceptions.SpeedwagonException,
                     AttributeError
@@ -475,6 +685,43 @@ def _find_invalid(
                 yield title, str(error)
 
 
+def report_exception_dialog(
+        exc: BaseException,
+        parent: typing.Optional[QtWidgets.QWidget] = None,
+        dialog_box_title: Optional[str] = None,
+        is_fatal=True,
+) -> None:
+    error_dialog = \
+        dialog.dialogs.SpeedwagonExceptionDialog(parent)
+    if dialog_box_title:
+        error_dialog.setWindowTitle(dialog_box_title)
+    if is_fatal is True:
+        error_dialog.setText(
+            "Speedwagon has exited due to an unhandled exception"
+        )
+    error_dialog.exception = exc
+    error_dialog.exec()
+
+
+def gui_exceptions_hook(
+        cls: Type[BaseException],
+        exception: BaseException,
+        traceback: Optional[types.TracebackType],
+        app: Optional[QtWidgets.QApplication] = None,
+        window: Optional[QtWidgets.QMainWindow] = None,
+) -> None:
+    if window:
+        window.hide()
+    tb.print_tb(traceback)
+    report_exception_dialog(exception, window)
+    if window:
+        window.close()
+    print(f"Unhandled exception: {cls.__name__}")
+    print("Speedwagon exited prematurely")
+    if app is not None:
+        app.exit(1)
+
+
 class TabsEditorApp(QtWidgets.QDialog):
     """Dialog box for editing tabs.yml file."""
 
@@ -484,7 +731,9 @@ def __init__(self, *args, **kwargs) -> None:
         self.setWindowTitle("Speedwagon Tabs Editor")
         layout = QtWidgets.QVBoxLayout()
         self.editor = speedwagon.frontend.qtwidgets.dialog.settings.TabEditor()
+        # self.editor.tabs_model.
         layout.addWidget(self.editor)
+        layout.setContentsMargins(0, 0, 0, 0)
         self.dialog_button_box = QtWidgets.QDialogButtonBox(self)
         layout.addWidget(self.dialog_button_box)
 
@@ -504,138 +753,58 @@ def __init__(self, *args, **kwargs) -> None:
         self.rejected.connect(self.on_cancel)  # type: ignore
 
     def load_all_workflows(self) -> None:
-        workflows = speedwagon.job.available_workflows()
-        self.editor.set_all_workflows(workflows)
+        self.editor.set_all_workflows()
+
+    def get_tab_config_strategy(self) -> config.AbsTabsConfigDataManagement:
+        config_strategy = config.StandardConfigFileLocator()
+        return config.CustomTabsYamlConfig(config_strategy.get_tabs_file())
 
     def on_okay(self) -> None:
+        config_strategy = config.StandardConfigFileLocator()
+        tabs_file = config_strategy.get_tabs_file()
         if self.editor.modified is True:
-            if self.tabs_file is None:
+            if tabs_file is None:
                 return
-            speedwagon.frontend.qtwidgets.tabs.write_tabs_yaml(
-                self.tabs_file,
-                speedwagon.frontend.qtwidgets.tabs.extract_tab_information(
-                    cast(
-                        speedwagon.frontend.qtwidgets.models.TabsModel,
-                        self.editor.selected_tab_combo_box.model()
+            strategy = self.get_tab_config_strategy()
+            strategy.save(
+                list(
+                    filter(
+                        lambda tab: tab.tab_name != "All",
+                        self.editor.model.tab_information(),
                     )
                 )
             )
-
         self.close()
 
     def on_cancel(self) -> None:
         self.close()
 
-    def load_tab_file(self, filename: str) -> None:
-        """Load tab file."""
-        self.editor.tabs_file = filename
-
-    @property
-    def tabs_file(self) -> Optional[str]:
-        return self.editor.tabs_file
-
-    @tabs_file.setter
-    def tabs_file(self, value: str) -> None:
-        self.editor.tabs_file = value
-
 
 def standalone_tab_editor(
         app: Optional[QtWidgets.QApplication] = None
 ) -> None:
     """Launch standalone tab editor app."""
-    print("Loading settings")
-    settings = config.get_platform_settings()
 
+    print("Loading settings")
     app = app or QtWidgets.QApplication(sys.argv)
-    print("Loading tab editor")
-    editor = TabsEditorApp()
-    editor.load_all_workflows()
-
-    tabs_file = \
-        os.path.join(
-            settings.get_app_data_directory(),
-            speedwagon.startup.TABS_YML_FILE_NAME
-        )
-
-    editor.load_tab_file(tabs_file)
-
-    print("displaying tab editor")
-    editor.show()
-    app.exec()
-
-
-class SingleWorkflowLauncher(AbsGuiStarter):
-    """Single workflow launcher.
-
-    .. versionadded:: 0.2.0
-       Added SingleWorkflowLauncher class for running a single workflow \
-            without user interaction. Useful for building new workflows.
-
-    """
 
-    def __init__(
-            self,
-            app,
-            logger: typing.Optional[logging.Logger] = None
+    def exception_hook(
+            cls: Type[BaseException],
+            exception: BaseException,
+            traceback: Optional[types.TracebackType]
     ) -> None:
-        """Set up window for running a single workflow."""
-        super().__init__(app)
-        self.window: Optional[
-            speedwagon.frontend.qtwidgets.gui.MainWindow1
-        ] = None
-        self._active_workflow: Optional[AbsWorkflow] = None
-        self.options: Dict[str, Union[str, bool]] = {}
-        self.logger = logger or logging.getLogger(__name__)
-
-    def start_gui(self, app: Optional[QtWidgets.QApplication] = None) -> int:
-        """Run the workflow configured with the options given."""
-        if self._active_workflow is None:
-            raise AttributeError("Workflow has not been set")
-
-        with speedwagon.frontend.qtwidgets.worker.ToolJobManager() \
-                as work_manager:
-            work_manager.logger = self.logger
-            self._run(work_manager)
-        return 0
+        gui_exceptions_hook(cls, exception, traceback, app=app)
 
-    def _run(
-            self,
-            work_manager: speedwagon.frontend.qtwidgets.worker.ToolJobManager
-    ) -> None:
-        if self._active_workflow is None:
-            raise ValueError("No active workflow set")
-
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow1(
-            work_manager=work_manager,
-            debug=False)
-
-        window.show()
-        if self._active_workflow.name is not None:
-            window.setWindowTitle(self._active_workflow.name)
-        runner_strategy = \
-            speedwagon.frontend.qtwidgets.runners.QtRunner(window)
-
-        self._active_workflow.validate_user_options(**self.options)
-        # runner_strategy.additional_info_callback
-
-        runner_strategy.run(self._active_workflow,
-                            self.options,
-                            window.log_manager)
-        window.log_manager.handlers.clear()
-        window.close()
+    sys.excepthook = exception_hook
 
-    def set_workflow(self, workflow: AbsWorkflow) -> None:
-        """Set the current workflow."""
-        self._active_workflow = workflow
+    print("Loading tab editor")
+    dialog_box = TabsEditorApp()
 
+    dialog_box.editor.load_data()
 
-def report_exception_dialog(exc, dialog_box_title, parent):
-    text = str(exc)
-    dialog_box = QtWidgets.QMessageBox(parent)
-    if dialog_box_title is not None:
-        dialog_box.setWindowTitle(dialog_box_title)
-    dialog_box.setText(text)
-    dialog_box.exec_()
+    print("displaying tab editor")
+    dialog_box.show()
+    sys.exit(app.exec())
 
 
 class SingleWorkflowJSON(AbsGuiStarter):
@@ -656,7 +825,7 @@ def __init__(self, app, logger: Optional[logging.Logger] = None) -> None:
         self.global_settings = None
         self.on_exit: typing.Optional[
             typing.Callable[
-                [speedwagon.frontend.qtwidgets.gui.MainWindow2], None]
+                [speedwagon.frontend.qtwidgets.gui.MainWindow3], None]
         ] = None
         self.options: typing.Optional[typing.Dict[str, typing.Any]] = None
         self.workflow: typing.Optional[AbsWorkflow] = None
@@ -703,19 +872,6 @@ def start_gui(self, app: Optional[QtWidgets.QApplication] = None) -> int:
                 app.quit()
         return 0
 
-    def report_exception(
-            self,
-            exc: BaseException,
-            parent: typing.Optional[QtWidgets.QWidget] = None,
-            dialog_box_title: Optional[str] = None,
-    ) -> None:
-        self.logger.error(str(exc))
-        report_exception_dialog(
-            exc=exc,
-            dialog_box_title=dialog_box_title,
-            parent=parent
-        )
-
     def _run_workflow(
             self,
             job_manager: runner_strategies.BackgroundJobManager,
@@ -728,10 +884,10 @@ def _run_workflow(
                 raise ValueError(f"Unknown workflow: '{workflow}'")
             workflow.validate_user_options(**options)
         except ValueError as user_option_error:
-            self.report_exception(
+            report_exception_dialog(
                 parent=None,
                 exc=user_option_error,
-                dialog_box_title="Invalid User Options"
+                dialog_box_title="Invalid User Options",
             )
             return
 
@@ -765,64 +921,10 @@ def _run_workflow(
     def _load_main_window(
             job_manager: runner_strategies.BackgroundJobManager,
             title: Optional[str]
-    ) -> gui.MainWindow2:
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow2(job_manager)
+    ) -> gui.MainWindow3:
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        window.job_manager = job_manager
         if title is not None:
             window.setWindowTitle(title)
 
         return window
-
-
-class MultiWorkflowLauncher(AbsGuiStarter):
-
-    def __init__(self, app, logger:  Optional[logging.Logger] = None) -> None:
-        super().__init__(app)
-        self.logger = logger or logging.getLogger(__name__)
-        self._pending_tasks: \
-            "queue.Queue[Tuple[AbsWorkflow, Dict[str, typing.Any]]]" \
-            = queue.Queue()
-
-    def start_gui(self, app: Optional[QtWidgets.QApplication] = None) -> int:
-
-        with speedwagon.frontend.qtwidgets.worker.ToolJobManager() as \
-                work_manager:
-            work_manager.logger = self.logger
-            self._run(work_manager)
-        return 0
-
-    def _run(
-            self,
-            work_manager: speedwagon.worker.AbsToolJobManager
-    ) -> None:
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow1(
-            work_manager=work_manager,
-            debug=False)
-
-        window.show()
-        try:
-            while not self._pending_tasks.empty():
-                active_workflow, options = self._pending_tasks.get()
-                if active_workflow.name is not None:
-                    window.setWindowTitle(active_workflow.name)
-                runner_strategy = \
-                    speedwagon.frontend.qtwidgets.runners.QtRunner(window)
-
-                active_workflow.validate_user_options(**options)
-
-                runner_strategy.run(
-                    active_workflow,
-                    options,
-                    window.log_manager
-                )
-
-                self._pending_tasks.task_done()
-        except speedwagon.frontend.qtwidgets.runners.TaskFailed as task_error:
-            raise \
-                speedwagon.exceptions.JobCancelled(task_error) from task_error
-
-        finally:
-            window.log_manager.handlers.clear()
-            window.close()
-
-    def add_job(self, workflow, args):
-        self._pending_tasks.put((workflow, args))
diff --git a/speedwagon/frontend/qtwidgets/logging_helpers.py b/speedwagon/frontend/qtwidgets/logging_helpers.py
index ca0c22143..8321d1c65 100644
--- a/speedwagon/frontend/qtwidgets/logging_helpers.py
+++ b/speedwagon/frontend/qtwidgets/logging_helpers.py
@@ -3,49 +3,15 @@
 
 import abc
 import logging
+from logging.handlers import BufferingHandler
 import typing
-from logging import handlers
 
-from typing import Callable
+from typing import Optional
+from PySide6 import QtCore
 if typing.TYPE_CHECKING:
     from logging import LogRecord
-    from PySide6 import QtCore
 
-
-class GuiLogHandler(handlers.BufferingHandler):
-    """Logger designed for gui."""
-
-    def __init__(
-            self,
-            callback: Callable[[str], None],
-    ) -> None:
-        """Create a gui log handler."""
-        super().__init__(capacity=5)
-        self.callback = callback
-
-    def emit(self, record: LogRecord) -> None:
-        """Emit logged message to callback function."""
-        self.callback(logging.Formatter().format(record))
-
-
-class SignalLogHandler(handlers.BufferingHandler):
-    """Qt Signal based log handler.
-
-    Emits the log as a signal.
-
-    Warnings:
-         This is problematic, the signal could be GC and cause a segfault
-    """
-
-    def __init__(self, signal: QtCore.SignalInstance) -> None:
-        """Create a new log handler for Qt signals."""
-        super().__init__(capacity=10)
-        self._signal = signal
-
-    def emit(self, record: LogRecord) -> None:
-        """Emit the record."""
-        result = logging.Formatter().format(record)
-        self._signal.emit(result, record.levelno)
+__all__ = ['QtSignalLogHandler']
 
 
 class AbsConsoleFormatter(abc.ABC):
@@ -102,7 +68,7 @@ class VerboseConsoleFormatStyle(AbsConsoleFormatter):
     """
 
     @staticmethod
-    def _basic_format(record: LogRecord):
+    def _basic_format(record: LogRecord) -> str:
         return logging.Formatter(
             '[%(levelname)s] (%(threadName)-10s) %(message)s'
         ).format(record).replace("\n", "<br>")
@@ -142,7 +108,7 @@ def __init__(self, *args, **kwargs) -> None:
         self.verbose = False
 
     def format(self, record: LogRecord) -> str:
-        """Format record for an html based console."""
+        """Format record for html based consoles."""
         formatters: typing.Dict[bool, typing.Type[AbsConsoleFormatter]] = {
             False: DefaultConsoleFormatStyle,
             True: VerboseConsoleFormatStyle,
@@ -164,3 +130,42 @@ def format(self, record: LogRecord) -> str:
             return formatter.format_error(text, record)
 
         return formatter.format_info(text, record)
+
+
+class QtSignalLogHandler(BufferingHandler):
+    """Log handler for Qt signals."""
+
+    class Signals(QtCore.QObject):  # pylint: disable=R0903
+        """Qt Signals.
+
+        This needs of be an inner class because Qt/PySide does not like mixing
+        Qt parent classes with Python ones.
+        """
+
+        messageSent = QtCore.Signal(str)
+    # This needs of be an inner class because Qt/PySide does not like mixing
+    # Qt parent classes with Python ones.
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new QtSignalLogHandler object.
+
+        Args:
+            parent: Qt Object the handler is tied to keep it from being GC or
+                deleted too early.
+        """
+        super().__init__(capacity=100)
+        self.signals = self.Signals(parent)
+        self.flush_timer = QtCore.QTimer(parent)
+        self.flush_timer.timeout.connect(self.flush)
+        self.flush_timer.start(100)
+
+    def flush(self) -> None:
+        """Flush log buffer.
+
+        Notes:
+            If the buffer is empty, no signal with be emitted.
+        """
+        if len(self.buffer) > 0:
+            results = [self.format(log).strip() for log in self.buffer]
+            self.signals.messageSent.emit("".join(results))
+        super().flush()
diff --git a/speedwagon/frontend/qtwidgets/models.py b/speedwagon/frontend/qtwidgets/models.py
deleted file mode 100644
index 65496acbd..000000000
--- a/speedwagon/frontend/qtwidgets/models.py
+++ /dev/null
@@ -1,828 +0,0 @@
-"""Data models for displaying data to user in the user interface."""
-from __future__ import annotations
-import abc
-from collections import namedtuple, OrderedDict
-import configparser
-import enum
-import io
-import os
-
-import sys
-import typing
-from typing import Type, Dict, List, Any, Union, Tuple, Optional, cast
-
-try:
-    from typing import Final
-except ImportError:
-    from typing_extensions import Final  # type: ignore
-
-import warnings
-
-from PySide6.QtCore import QAbstractItemModel
-from PySide6 import QtCore, QtGui  # type: ignore
-if typing.TYPE_CHECKING:
-    from speedwagon.frontend.qtwidgets import tabs
-    from speedwagon.job import AbsWorkflow, Workflow
-    from speedwagon.workflow import AbsOutputOptionDataType
-
-
-__all__ = [
-    "ItemListModel",
-    "WorkflowListModel",
-    "WorkflowListModel2",
-    "ToolOptionsPairsModel",
-    "ToolOptionsModel4",
-    "SettingsModel",
-    "TabsModel"
-]
-
-QtConstant = int
-
-# Qt has non-pythonic names for it's methods
-# pylint: disable=invalid-name, unused-argument
-
-
-class JobModelData(enum.Enum):
-    NAME = 0
-    DESCRIPTION = 1
-
-
-class ItemListModel(QtCore.QAbstractTableModel):
-    """List model for items."""
-
-    def __init__(self, data: typing.Mapping[str, Type[Workflow]]) -> None:
-        """Create a new ItemListModel qt list model for workflows."""
-        super().__init__()
-        self.jobs: List[Type[Workflow]] = list(data.values())
-
-    def columnCount(self, *args, parent=QtCore.QModelIndex(), **kwargs) -> int:
-        """Return 2.
-
-        One for the label and one of the idem.
-        """
-        return 2
-
-    def rowCount(
-            self,
-            *args,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None,
-            **kwargs) -> int:
-        """Get the number of jobs in the model."""
-        return len(self.jobs)
-
-    @staticmethod
-    def _extract_job_metadata(
-            job: Type[AbsWorkflow],
-            data_type: JobModelData
-    ) -> Optional[str]:
-        static_data_values: Dict[JobModelData, Optional[str]] = {
-            JobModelData.NAME: job.name,
-            JobModelData.DESCRIPTION: job.description
-        }
-        return static_data_values[data_type]
-
-
-OptionPair = namedtuple("OptionPair", ("label", "data"))
-
-
-class WorkflowListModel(ItemListModel):
-    """Model for listing workflows."""
-
-    def data(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            role: Optional[QtConstant] = None
-    ) -> Optional[Union[str, Type[Workflow], QtCore.QSize, QtCore.QObject]]:
-        """Get data at a specific index."""
-        if index.isValid():
-            data = self.jobs[index.row()]
-            if role in [
-                QtCore.Qt.ItemDataRole.DisplayRole,
-                QtCore.Qt.ItemDataRole.EditRole
-            ]:
-                return self._extract_job_metadata(
-                    job=data,
-                    data_type=JobModelData(index.column())
-                ) or None
-
-            if role == QtCore.Qt.ItemDataRole.UserRole:
-                return self.jobs[index.row()]
-            if role == QtCore.Qt.ItemDataRole.SizeHintRole:
-                return QtCore.QSize(10, 20)
-
-        return None
-
-    def sort(self, key=None, order=None):
-        """Sort workflows.
-
-        Defaults alphabetically by title.
-        """
-        # pylint: disable=no-member
-        self.layoutAboutToBeChanged.emit()  # type: ignore
-
-        self.jobs.sort(key=key or (lambda i: i.name))
-        self.layoutChanged.emit()  # type: ignore
-
-
-class WorkflowListModel2(QtCore.QAbstractListModel):
-    """Workflow Qt list model."""
-
-    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
-        """Create a new WorkflowListModel2 qt list model."""
-        super().__init__(parent)
-        self.workflows: List[Type[Workflow]] = []
-
-    def __iadd__(self, other: Type["Workflow"]) -> "WorkflowListModel2":
-        """Add a workflow to the model."""
-        self.add_workflow(other)
-        return self
-
-    def __isub__(self, other: Type["Workflow"]) -> "WorkflowListModel2":
-        """Remove a workflow from the model."""
-        self.remove_workflow(other)
-        return self
-
-    def rowCount(
-            self,
-            *args,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None,
-            **kwargs
-    ) -> int:
-        """Get the number of workflows loaded in the model."""
-        return len(self.workflows)
-
-    def data(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            role: Optional[QtConstant] = None
-    ) -> Optional[Union[str, Type[Workflow], QtCore.QObject]]:
-        """Get data at specific index."""
-        if not index.isValid():
-            return None
-        row = index.row()
-
-        if role is None:
-            return None
-        workflow: Dict[QtCore.Qt.ItemDataRole,
-                       Optional[Union[str,
-                                      Type[Workflow],
-                                      QtCore.QObject]]] = {
-            QtCore.Qt.ItemDataRole.DisplayRole: self.workflows[row].name,
-            QtCore.Qt.ItemDataRole.UserRole: self.workflows[row],
-        }
-        value = workflow.get(typing.cast(QtCore.Qt.ItemDataRole, role))
-        if value is not None:
-            return value
-        return None
-
-    def sort(self, key=None, order=None) -> None:
-        """Sort workflows.
-
-        Defaults alphabetically by title.
-        """
-        # pylint: disable=no-member
-        self.layoutAboutToBeChanged.emit()  # type: ignore
-
-        self.workflows.sort(key=key or (lambda i: i.name))
-        self.layoutChanged.emit()  # type: ignore
-
-    def add_workflow(self, workflow: Type[Workflow]) -> None:
-        """Add workflow to model."""
-        for existing_workflow in self.workflows:
-            if workflow.name == existing_workflow.name:
-                break
-        else:
-            row = len(self.workflows)
-            index = self.createIndex(row, 0)
-            self.beginInsertRows(index, 0, self.rowCount())
-            self.workflows.insert(row, workflow)
-
-            # pylint: disable=no-member
-            self.dataChanged.emit(  # type: ignore
-                index,
-                index,
-                [QtCore.Qt.ItemDataRole.EditRole]
-            )
-            self.endInsertRows()
-
-    def setData(self,
-                index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-                workflow: Type[Workflow],
-                role: Optional[QtConstant] = None) -> bool:
-        """Get data at a given index."""
-        if not index.isValid():
-            return False
-
-        if workflow not in self.workflows:
-            return False
-
-        row = index.row()
-
-        if len(self.workflows) <= row:
-            return False
-
-        self.workflows[row] = workflow
-
-        # pylint: disable=no-member
-        self.dataChanged.emit(index, index, [role])  # type: ignore
-        return True
-
-    def remove_workflow(self, workflow: Type[Workflow]) -> None:
-        """Remove workflow from the model."""
-        if workflow in self.workflows:
-            index = QtCore.QModelIndex()
-            self.beginRemoveRows(index, 0, self.rowCount())
-            self.workflows.remove(workflow)
-
-            # pylint: disable=no-member
-            self.dataChanged.emit(  # type: ignore
-                index,
-                index,
-                [QtCore.Qt.ItemDataRole.EditRole]
-            )
-            self.endRemoveRows()
-
-
-class ToolOptionsModel(QtCore.QAbstractTableModel):
-    """Tool options Qt table model."""
-
-    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
-        """Create a new ToolOptionsModel qt table model."""
-        super().__init__(parent)
-        self._data: List[Any] = []
-
-    def rowCount(
-            self,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None
-    ) -> int:
-        return len(self._data)
-
-    def columnCount(
-            self,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None
-    ) -> int:
-        if len(self._data) > 0:
-            return 1
-        return 0
-
-    @abc.abstractmethod
-    def get(self):
-        raise NotImplementedError
-
-    def flags(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-    ):
-        """Get flags for a given index."""
-        if not index.isValid():
-            return QtCore.Qt.ItemFlag.ItemIsEnabled
-
-        column = index.column()
-
-        if column == 0:
-            return QtCore.Qt.ItemFlag.ItemIsEnabled \
-                   | QtCore.Qt.ItemFlag.ItemIsSelectable \
-                   | QtCore.Qt.ItemFlag.ItemIsEditable
-        print(column, file=sys.stderr)
-        return QtCore.Qt.ItemFlag.NoItemFlags
-
-
-class ToolOptionsPairsModel(ToolOptionsModel):
-    """Tool Options Pairs Qt table model.
-
-    Warnings:
-        This class is deprecated. Use ToolOptionsModel2 instead.
-    """
-
-    def __init__(self, data: Dict[str, str], parent=None) -> None:
-        """Create a new ToolOptionsPairsModel model.
-
-        Warnings:
-            This is deprecated. Use ToolOptionsModel2 instead.
-        """
-        warnings.warn("Use ToolOptionsModel2 instead", DeprecationWarning)
-        super().__init__(parent)
-        for key, value in data.items():
-            self._data.append(OptionPair(key, value))
-
-    def data(self,
-             index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-             role: Optional[QtConstant] = None):
-        """Get data at a certain index."""
-        if index.isValid():
-            if role == QtCore.Qt.ItemDataRole.DisplayRole:
-                return self._data[index.row()].data
-            if role == QtCore.Qt.ItemDataRole.EditRole:
-                return self._data[index.row()].data
-        return None
-
-    def setData(
-            self,
-            index,
-            data,
-            role=None
-    ) -> bool:
-        """Set data at a certain index."""
-        if not index.isValid():
-            return False
-        existing_data = self._data[index.row()]
-        self._data[index.row()] = OptionPair(existing_data.label, data)
-        return True
-
-    def headerData(
-            self,
-            index: int,
-            orientation: QtCore.Qt.Orientation,
-            role: Optional[QtConstant] = None
-    ) -> Union[None, str, QtCore.QObject]:
-        """Get header information."""
-        if orientation == QtCore.Qt.Orientation.Vertical \
-                and role == QtCore.Qt.ItemDataRole.DisplayRole:
-            title = self._data[index].label
-            return str(title)
-        return None
-
-    def get(self) -> dict:
-        """Access all underlining data."""
-        return {data.label: data.data for data in self._data}
-
-
-def _lookup_constant(value: int) -> List[str]:
-    res = []
-    for m in [
-        attr for attr in dir(QtCore.Qt)
-        if not callable(getattr(QtCore.Qt, attr)) and not attr.startswith("_")
-    ]:
-
-        if getattr(QtCore.Qt, m) == value and "role" in m.lower():
-            res.append(m)
-    return res
-
-
-class ToolOptionsModel4(QtCore.QAbstractListModel):
-    """Tool Model Options."""
-
-    JsonDataRole = cast(int, QtCore.Qt.ItemDataRole.UserRole) + 1
-    DataRole = JsonDataRole + 1
-
-    def __init__(
-            self,
-            data: Optional[List[AbsOutputOptionDataType]] = None,
-            parent: Optional[QtCore.QObject] = None
-    ) -> None:
-        """Create a new ToolOptionsModel4 object."""
-        super().__init__(parent)
-        self._data = data or []
-
-    def __setitem__(
-            self,
-            key: str,
-            value: Optional[Union[str, int, bool]]
-    ) -> None:
-        """Set the [key] operator.
-
-        This allows for looking up the data based on the key.
-        """
-        if self._data is None:
-            raise IndexError("No data")
-
-        for item in self._data:
-            if item.label == key:
-                item.value = value
-                break
-        else:
-            raise KeyError(f"Key not found: {key}")
-
-    def flags(
-            self,
-            index: Union[
-                QtCore.QModelIndex,
-                QtCore.QPersistentModelIndex
-            ]) -> QtCore.Qt.ItemFlag:
-        """Get Qt Widget item flags used for an index."""
-        return QtCore.Qt.ItemFlag.ItemIsSelectable | \
-            QtCore.Qt.ItemFlag.ItemIsEnabled | \
-            QtCore.Qt.ItemFlag.ItemIsEditable
-
-    def rowCount(
-            self,
-            parent: Optional[
-                Union[
-                    QtCore.QModelIndex,
-                    QtCore.QPersistentModelIndex
-                ]
-            ] = None
-    ) -> int:
-        """Get the amount of entries in the model."""
-        return len(self._data)
-
-    def headerData(
-            self,
-            section: int,
-            orientation: QtCore.Qt.Orientation,
-            role: int = cast(int, QtCore.Qt.ItemDataRole.DisplayRole)
-    ) -> Any:
-        """Get model header data."""
-        if orientation == QtCore.Qt.Orientation.Vertical and \
-                role == QtCore.Qt.ItemDataRole.DisplayRole:
-            return self._data[section].label
-        return None
-
-    def data(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            role: int = typing.cast(int, QtCore.Qt.ItemDataRole.DisplayRole)
-    ) -> Optional[Any]:
-        """Get data from model."""
-        if not index.isValid():
-            return None
-
-        formatter = ModelDataFormatter(self)
-        return formatter.format(
-            setting=self._data[index.row()],
-            role=typing.cast(QtCore.Qt.ItemDataRole, role)
-        )
-
-    def setData(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            value: Optional[Any],
-            role: int = typing.cast(int, QtCore.Qt.ItemDataRole.EditRole)
-    ) -> bool:
-        """Set model data.
-
-        Returns:
-            True if successful
-        """
-        if value is None:
-            return False
-
-        if role == typing.cast(int, QtCore.Qt.ItemDataRole.EditRole):
-            self._data[index.row()].value = value
-            self.dataChanged.emit(index, index, [role])  # type: ignore
-            return True
-        return super().setData(index, value, role)
-
-    def serialize(self):
-        """Serialize model data to a dictionary."""
-        return {data.label: data.value for data in self._data}
-
-    def get(self) -> Dict[str, Any]:
-        """Access the key value settings for all options."""
-        return self.serialize()
-
-
-def check_required_settings_have_values(
-        option_data: AbsOutputOptionDataType
-) -> Optional[str]:
-    if option_data.required is False:
-        return None
-    if option_data.value is None or option_data.value == "":
-        return f"Required setting '{option_data.label}' is missing value"
-    return None
-
-
-def get_settings_errors(
-        model: ToolOptionsModel4,
-        checks: List[typing.Callable[[AbsOutputOptionDataType], Optional[str]]]
-) -> List[str]:
-    errors = []
-    for row_id in range(model.rowCount()):
-        index = model.index(row_id)
-        data = model.data(index, model.DataRole)
-        for check_func in checks:
-            error_check_result = check_func(data)
-            if error_check_result is not None:
-                errors.append(error_check_result)
-    return errors
-
-
-class ModelDataFormatter:
-    def __init__(self, model: ToolOptionsModel4):
-        self._model = model
-
-    @classmethod
-    def _select_display_role(
-            cls,
-            item: AbsOutputOptionDataType
-    ) -> Optional[str]:
-        if cls._should_use_placeholder_text(item) is True:
-            return item.placeholder_text
-        if isinstance(item.value, bool):
-            return item.value
-        if item.value is None:
-            return item.value
-        return str(item.value)
-
-    @staticmethod
-    def _should_use_placeholder_text(
-            item: AbsOutputOptionDataType
-    ) -> bool:
-        if item.value is not None:
-            return False
-        if item.placeholder_text is None:
-            return False
-        return True
-
-    def font_role(
-            self,
-            setting: AbsOutputOptionDataType
-    ) -> Optional[QtGui.QFont]:
-        if self._should_use_placeholder_text(setting) is True:
-            font = QtGui.QFont()
-            font.setItalic(True)
-            return font
-        return None
-
-    def display_role(
-            self,
-            setting: AbsOutputOptionDataType
-    ) -> Optional[str]:
-        return self._select_display_role(setting)
-
-    def format(
-            self,
-            setting: AbsOutputOptionDataType,
-            role: QtCore.Qt.ItemDataRole
-    ) -> Optional[Any]:
-        formatter = {
-            QtCore.Qt.ItemDataRole.DisplayRole: self.display_role,
-            QtCore.Qt.ItemDataRole.EditRole: lambda setting_: setting_.value,
-            QtCore.Qt.ItemDataRole.FontRole: self.font_role,
-            self._model.JsonDataRole:
-                lambda setting_: setting_.build_json_data(),
-            self._model.DataRole: lambda setting_: setting_
-        }.get(role)
-
-        if formatter is not None:
-            return formatter(setting)
-
-        return None
-
-
-class SettingsModel(QtCore.QAbstractTableModel):
-    """Settings Qt table model."""
-
-    columns: Final[int] = 2
-
-    def __init__(self, *__args) -> None:
-        """Create a new settings Qt model."""
-        super().__init__(*__args)
-        self._data: List[Tuple[str, str]] = []
-        self._headers = {
-            0: "Key",
-            1: "Value"
-        }
-
-    def data(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            role: Optional[QtConstant] = None
-    ) -> Optional[Union[str, QtCore.QObject]]:
-        """Get role data from an index."""
-        if not index.isValid():
-            return None
-
-        if role == QtCore.Qt.ItemDataRole.DisplayRole:
-            return self._data[index.row()][index.column()]
-
-        if role == QtCore.Qt.ItemDataRole.EditRole:
-            return self._data[index.row()][index.column()]
-
-        return None
-
-    def rowCount(
-            self,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None
-    ) -> int:
-        """Return the number of settings loaded in the model."""
-        return len(self._data)
-
-    def add_setting(self, name: str, value: str) -> None:
-        """Add setting key value to the settings."""
-        self._data.append((name, value))
-
-    def columnCount(
-            self,
-            parent: Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None
-    ) -> int:
-        """Return number of columns.
-
-        One for the heading and one for the content.
-        """
-        return self.columns
-
-    def headerData(
-            self,
-            index: int,
-            orientation: QtCore.Qt.Orientation,
-            role: Optional[QtConstant] = None
-    ) -> Optional[Union[str, QtCore.QObject]]:
-        """Get header data from settings."""
-        if orientation == QtCore.Qt.Orientation.Horizontal and \
-                role == QtCore.Qt.ItemDataRole.DisplayRole:
-            return self._headers.get(index, "")
-        return None
-
-    def flags(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-    ) -> QtCore.Qt.ItemFlag:
-        """Manage display flags for a given index."""
-        if self._headers.get(index.column(), "") == "Key":
-            return cast(QtCore.Qt.ItemFlag, QtCore.Qt.ItemFlag.NoItemFlags)
-
-        if self._headers.get(index.column(), "") == "Value":
-            return cast(
-                QtCore.Qt.ItemFlag,
-                QtCore.Qt.ItemFlag.ItemIsEnabled |
-                QtCore.Qt.ItemFlag.ItemIsEditable
-            )
-
-        return super().flags(index)
-
-    def setData(
-            self,
-            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
-            data: Any,
-            role: Optional[QtConstant] = None
-    ) -> bool:
-        """Set data in model."""
-        if not index.isValid():
-            return False
-        row = index.row()
-        original_data = self._data[row]
-
-        # Only update the model if the data is actually different
-        if data != original_data[1]:
-            self._data[row] = (self._data[row][0], data)
-
-            # pylint: disable=no-member
-            self.dataChanged.emit(  # type: ignore
-                index,
-                index,
-                [QtCore.Qt.ItemDataRole.EditRole]
-            )
-
-            return True
-        return False
-
-
-class TabsModel(QtCore.QAbstractListModel):
-    """Tabs Qt list Model."""
-
-    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
-        """Create a new tab qt list model."""
-        super().__init__(parent)
-        self.tabs: List[tabs.TabData] = []
-
-    def __contains__(self, value: str) -> bool:
-        """Check if a tab is in the model."""
-        return any(tab.tab_name == value for tab in self.tabs)
-
-    def __iadd__(self, other: tabs.TabData) -> "TabsModel":
-        """Add a tab to the model."""
-        self.add_tab(other)
-        return self
-
-    def __isub__(self, other: tabs.TabData) -> "TabsModel":
-        """Remove a tab from the model."""
-        self.remove_tab(other)
-        return self
-
-    def data(self,
-             index: Union[
-                 QtCore.QModelIndex,
-                 QtCore.QPersistentModelIndex
-             ],
-             role: Optional[QtConstant] = None
-             ) -> Optional[Union[str, tabs.TabData]]:
-        """Get data about a tab for an index."""
-        if not index.isValid():
-            return None
-
-        row = index.row()
-        if row > len(self.tabs):
-            return None
-
-        if role is not None:
-            workflow: Dict[QtCore.Qt.ItemDataRole, Any] = {
-                QtCore.Qt.ItemDataRole.DisplayRole: self.tabs[row].tab_name,
-                QtCore.Qt.ItemDataRole.UserRole: self.tabs[row]
-            }
-            return workflow.get(typing.cast(QtCore.Qt.ItemDataRole, role))
-        return None
-
-    def rowCount(
-            self,
-            parent: typing.Optional[
-                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
-            ] = None
-    ) -> int:
-        """Get the number of tabs loaded in the model."""
-        return len(self.tabs)
-
-    def add_tab(self, tab: tabs.TabData) -> None:
-        """Add a new tab to the model."""
-        row = len(self.tabs)
-        index = self.createIndex(row, 0)
-        self.beginInsertRows(index, 0, self.rowCount())
-        self.tabs.insert(row, tab)
-
-        # pylint: disable=no-member
-        self.dataChanged.emit(  # type: ignore
-            index,
-            index,
-            [QtCore.Qt.ItemDataRole.EditRole]
-        )
-        self.endInsertRows()
-
-    def remove_tab(self, tab: tabs.TabData) -> None:
-        """Remove a tab from the model."""
-        index = QtCore.QModelIndex()
-        if tab in self.tabs:
-            self.beginRemoveRows(index, 0, self.rowCount())
-            self.tabs.remove(tab)
-
-            # pylint: disable=no-member
-            self.dataChanged.emit(  # type: ignore
-                index,
-                index,
-                [QtCore.Qt.ItemDataRole.EditRole]
-            )
-            self.endRemoveRows()
-
-    def setData(
-            self,
-            index: Union[
-                QtCore.QModelIndex,
-                QtCore.QPersistentModelIndex
-            ],
-            tab: tabs.TabData,
-            role: Optional[QtConstant] = None
-    ) -> bool:
-        """Set tab data."""
-        if not index.isValid():
-            return False
-
-        if tabs in self.tabs:
-            return False
-
-        row = index.row()
-        if len(self.tabs) <= row:
-            return False
-
-        self.tabs[row] = tab
-
-        # pylint: disable=no-member
-        self.dataChanged.emit(index, index, [role])  # type: ignore
-        return True
-
-
-def build_setting_qt_model(config_file: str) -> SettingsModel:
-    """Read a configuration file and generate a SettingsModel."""
-    if not os.path.exists(config_file):
-        raise FileNotFoundError(f"No existing Configuration in ${config_file}")
-
-    config = configparser.ConfigParser()
-    config.read(config_file)
-    global_settings = config["GLOBAL"]
-    my_model = SettingsModel()
-    for key, value in global_settings.items():
-        my_model.add_setting(key, value)
-    return my_model
-
-
-def serialize_settings_model(model: QAbstractItemModel) -> str:
-    """Convert a SettingsModel into a format that can be written to a file.
-
-    Note:
-        This only generates and returns a string. You are still responsible to
-        write that data to a file.
-
-    """
-    config_data = configparser.ConfigParser()
-    config_data["GLOBAL"] = {}
-    global_data: Dict[str, str] = OrderedDict()
-
-    for i in range(model.rowCount()):
-        key = model.index(i, 0).data()
-        value = model.index(i, 1).data()
-        global_data[key] = value
-    config_data["GLOBAL"] = global_data
-
-    with io.StringIO() as string_writer:
-        config_data.write(string_writer)
-        return string_writer.getvalue()
diff --git a/speedwagon/frontend/qtwidgets/models/__init__.py b/speedwagon/frontend/qtwidgets/models/__init__.py
new file mode 100644
index 000000000..d94afc470
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/__init__.py
@@ -0,0 +1,21 @@
+"""Data models for displaying data to user in the user interface."""
+from .options import ToolOptionsModel4
+from .tabs import TabsTreeModel, TabStandardItem, TabProxyModel
+from .workflows import WorkflowListProxyModel, WorkflowList
+from .plugins import PluginActivationModel
+from .settings import SettingsModel, WorkflowSettingsModel
+from .common import WorkflowItem, WorkflowClassRole
+
+__all__ = [
+    "TabsTreeModel",
+    "TabProxyModel",
+    "TabStandardItem",
+    "ToolOptionsModel4",
+    "PluginActivationModel",
+    "SettingsModel",
+    "WorkflowClassRole",
+    "WorkflowItem",
+    "WorkflowList",
+    "WorkflowListProxyModel",
+    "WorkflowSettingsModel",
+]
diff --git a/speedwagon/frontend/qtwidgets/models/common.py b/speedwagon/frontend/qtwidgets/models/common.py
new file mode 100644
index 000000000..924041728
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/common.py
@@ -0,0 +1,66 @@
+"""Common items used by all model code.
+
+This is mainly to avoid circular imports.
+"""
+from __future__ import annotations
+
+import abc
+from typing import Optional, Type, Union, Any, cast
+
+from PySide6 import QtGui, QtCore
+
+import speedwagon
+from speedwagon.job import Workflow
+
+__all__ = ["WorkflowItem", "WorkflowClassRole"]
+
+
+class WorkflowItem(QtGui.QStandardItem):
+    """Workflow metadata data."""
+
+    def __init__(self, workflow: Optional[Type[Workflow]]) -> None:
+        """Create a new Workflow item.
+
+        Args:
+            workflow:  Speedwagon Workflow
+        """
+        super().__init__()
+        self.workflow = workflow
+        if workflow is not None and workflow.name is not None:
+            self.setText(workflow.name)
+
+    def columnCount(self) -> int:  # pylint: disable=invalid-name
+        """Get column count."""
+        return 2
+
+    @property
+    def name(self) -> Optional[str]:
+        """Get the name used by the workflow."""
+        return None if self.workflow is None else self.workflow.name
+
+
+class AbsWorkflowItemData(abc.ABC):   # pylint: disable=too-few-public-methods
+    def data(
+            self,
+            workflow: Type[speedwagon.Workflow],
+            role: Union[int, QtCore.Qt.ItemDataRole]
+    ) -> Any:
+        """Get the data from workflow."""
+
+
+WorkflowClassRole = cast(int, QtCore.Qt.ItemDataRole.UserRole) + 1
+
+
+class WorkflowItemData(   # pylint: disable=too-few-public-methods
+    AbsWorkflowItemData
+):
+    def data(
+            self,
+            workflow: Type[speedwagon.Workflow],
+            role: Union[int, QtCore.Qt.ItemDataRole]
+    ) -> Any:
+        if role == QtCore.Qt.ItemDataRole.DisplayRole:
+            return workflow.name
+        if role == WorkflowClassRole:
+            return workflow
+        return None
diff --git a/speedwagon/frontend/qtwidgets/models/options.py b/speedwagon/frontend/qtwidgets/models/options.py
new file mode 100644
index 000000000..64a5b4ca2
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/options.py
@@ -0,0 +1,193 @@
+"""Settings model code."""
+
+from __future__ import annotations
+
+import typing
+from collections import namedtuple
+from typing import cast, Optional, List, Union, Any, Dict, TYPE_CHECKING
+
+from PySide6 import QtCore, QtGui
+
+if TYPE_CHECKING:
+    from speedwagon.config import SettingsDataType
+    from speedwagon.workflow import AbsOutputOptionDataType, UserDataType
+
+__all__ = ["ToolOptionsModel4"]
+
+OptionPair = namedtuple("OptionPair", ("label", "data"))
+
+
+class ToolOptionsModel4(QtCore.QAbstractListModel):
+    """Tool Model Options."""
+
+    JsonDataRole = cast(int, QtCore.Qt.ItemDataRole.UserRole) + 1
+    DataRole = JsonDataRole + 1
+
+    def __init__(
+            self,
+            data: Optional[List[AbsOutputOptionDataType]] = None,
+            parent: Optional[QtCore.QObject] = None
+    ) -> None:
+        """Create a new ToolOptionsModel4 object."""
+        super().__init__(parent)
+        self._data = data or []
+
+    def __setitem__(
+            self,
+            key: str,
+            value: Optional[Union[str, int, bool]]
+    ) -> None:
+        """Set the [key] operator.
+
+        This allows for looking up the data based on the key.
+        """
+        if self._data is None:
+            raise IndexError("No data")
+
+        for item in self._data:
+            if item.label == key:
+                item.value = value
+                break
+        else:
+            raise KeyError(f"Key not found: {key}")
+
+    def flags(
+            self,
+            index: Union[   # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]) -> QtCore.Qt.ItemFlag:
+        """Get Qt Widget item flags used for an index."""
+        return QtCore.Qt.ItemFlag.ItemIsSelectable | \
+            QtCore.Qt.ItemFlag.ItemIsEnabled | \
+            QtCore.Qt.ItemFlag.ItemIsEditable
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Optional[  # pylint: disable=unused-argument
+                Union[
+                    QtCore.QModelIndex,
+                    QtCore.QPersistentModelIndex
+                ]
+            ] = None
+    ) -> int:
+        """Get the amount of entries in the model."""
+        return len(self._data)
+
+    def headerData(  # pylint: disable=invalid-name
+            self,
+            section: int,
+            orientation: QtCore.Qt.Orientation,
+            role: int = cast(int, QtCore.Qt.ItemDataRole.DisplayRole)
+    ) -> Any:
+        """Get model header data."""
+        if orientation == QtCore.Qt.Orientation.Vertical and \
+                role == QtCore.Qt.ItemDataRole.DisplayRole:
+            return self._data[section].label
+        return None
+
+    def data(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+            role: int = typing.cast(int, QtCore.Qt.ItemDataRole.DisplayRole)
+    ) -> Optional[Any]:
+        """Get data from model."""
+        if not index.isValid():
+            return None
+
+        formatter = ModelDataFormatter(self)
+        return formatter.format(
+            setting=self._data[index.row()],
+            role=typing.cast(QtCore.Qt.ItemDataRole, role)
+        )
+
+    def setData(  # pylint: disable=invalid-name
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+            value: Optional[Any],
+            role: int = typing.cast(int, QtCore.Qt.ItemDataRole.EditRole)
+    ) -> bool:
+        """Set model data.
+
+        Returns:
+            True if successful
+        """
+        if value is None:
+            return False
+
+        if role == typing.cast(int, QtCore.Qt.ItemDataRole.EditRole):
+            self._data[index.row()].value = value
+            self.dataChanged.emit(index, index, [role])  # type: ignore
+            return True
+        return super().setData(index, value, role)
+
+    def serialize(self) -> Dict[str, UserDataType]:
+        """Serialize model data to a dictionary."""
+        return {data.label: data.value for data in self._data}
+
+    def get(self) -> Dict[str, UserDataType]:
+        """Access the key value settings for all options."""
+        return self.serialize()
+
+
+class ModelDataFormatter:
+    def __init__(self, model: ToolOptionsModel4):
+        self._model = model
+
+    @classmethod
+    def _select_display_role(
+            cls,
+            item: AbsOutputOptionDataType
+    ) -> Optional[SettingsDataType]:
+        if cls._should_use_placeholder_text(item) is True:
+            return item.placeholder_text
+        if isinstance(item.value, bool):
+            return item.value
+        if item.value is None:
+            return item.value
+        return item.value
+
+    @staticmethod
+    def _should_use_placeholder_text(
+            item: AbsOutputOptionDataType
+    ) -> bool:
+        if item.value is not None:
+            return False
+        if item.placeholder_text is None:
+            return False
+        return True
+
+    def font_role(
+            self,
+            setting: AbsOutputOptionDataType
+    ) -> Optional[QtGui.QFont]:
+        if self._should_use_placeholder_text(setting) is True:
+            font = QtGui.QFont()
+            font.setItalic(True)
+            return font
+        return None
+
+    def display_role(
+            self,
+            setting: AbsOutputOptionDataType
+    ) -> Optional[SettingsDataType]:
+        return self._select_display_role(setting)
+
+    def format(
+            self,
+            setting: AbsOutputOptionDataType,
+            role: QtCore.Qt.ItemDataRole
+    ) -> Optional[Any]:
+        formatter = {
+            QtCore.Qt.ItemDataRole.DisplayRole: self.display_role,
+            QtCore.Qt.ItemDataRole.EditRole: lambda setting_: setting_.value,
+            QtCore.Qt.ItemDataRole.FontRole: self.font_role,
+            self._model.JsonDataRole:
+                lambda setting_: setting_.build_json_data(),
+            self._model.DataRole: lambda setting_: setting_
+        }.get(role)
+
+        if formatter is not None:
+            return formatter(setting)
+
+        return None
diff --git a/speedwagon/frontend/qtwidgets/models/plugins.py b/speedwagon/frontend/qtwidgets/models/plugins.py
new file mode 100644
index 000000000..f38a38a60
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/plugins.py
@@ -0,0 +1,123 @@
+"""Plugin model code."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import cast, Optional, List, Union, Any
+import sys
+
+from PySide6 import QtCore
+
+if sys.version_info < (3, 10):  # pragma: no cover
+    import importlib_metadata as metadata
+else:  # pragma: no cover
+    from importlib import metadata
+
+__all__ = ["PluginActivationModel"]
+
+
+@dataclass
+class PluginModelItem:
+    entrypoint: metadata.EntryPoint
+    enabled: bool
+
+
+class PluginActivationModel(QtCore.QAbstractListModel):
+    """Plugin activation model.
+
+    For setting what plugins are active and which are not.
+    """
+
+    ModuleRole = cast(int, QtCore.Qt.ItemDataRole.UserRole) + 1
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new plugin model.
+
+        Args:
+            parent: Parent widget to control widget lifespan
+        """
+        super().__init__(parent)
+        self.data_modified = False
+        self._starting_data: List[PluginModelItem] = []
+        self._data: List[PluginModelItem] = []
+        self.dataChanged.connect(self._update_modified)
+
+    def _update_modified(self) -> None:
+        for original, current in zip(self._starting_data, self._data):
+            if original.enabled != current.enabled:
+                self.data_modified = True
+                return
+        self.data_modified = False
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+                None
+            ] = None
+    ) -> int:
+        """Get the number of plugins available."""
+        return len(self._data)
+
+    def add_entry_point(
+            self,
+            entry_point: metadata.EntryPoint,
+            enabled: bool = False
+    ) -> None:
+        """Add an entry point to the model."""
+        self._starting_data.append(PluginModelItem(entry_point, enabled))
+        self._data.append(PluginModelItem(entry_point, enabled))
+
+    def flags(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.Qt.ItemFlag:
+        """Item flags.
+
+        Should be user checkable, enabled, and selectable.
+        """
+        if index.isValid():
+            return (
+                QtCore.Qt.ItemFlag.ItemIsUserCheckable
+                | QtCore.Qt.ItemFlag.ItemIsSelectable
+                | QtCore.Qt.ItemFlag.ItemIsEnabled
+            )
+        return super().flags(index)
+
+    def data(
+        self,
+        index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+        role: int = QtCore.Qt.ItemDataRole.DisplayRole,
+    ) -> Any:
+        """Get the data from the model."""
+        if role == QtCore.Qt.ItemDataRole.CheckStateRole:
+
+            return (
+                QtCore.Qt.CheckState.Checked
+                if self._data[index.row()].enabled
+                else QtCore.Qt.CheckState.Unchecked
+            )
+
+        if role == self.ModuleRole:
+            return self._data[index.row()].entrypoint.module
+
+        if role == QtCore.Qt.ItemDataRole.DisplayRole:
+            return self._data[index.row()].entrypoint.name
+        return None
+
+    def setData(  # pylint: disable=invalid-name
+        self,
+        index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+        value: Any,
+        role: int = QtCore.Qt.ItemDataRole.EditRole,
+    ) -> bool:
+        """Set the data for the model."""
+        if role == QtCore.Qt.ItemDataRole.CheckStateRole:
+            self._data[index.row()].enabled = (
+                value == QtCore.Qt.CheckState.Checked.value
+            )
+            self.dataChanged.emit(index, index, (role,))
+            return True
+
+        return super().setData(index, value, role)
diff --git a/speedwagon/frontend/qtwidgets/models/settings.py b/speedwagon/frontend/qtwidgets/models/settings.py
new file mode 100644
index 000000000..2be0ecbbc
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/settings.py
@@ -0,0 +1,722 @@
+"""Settings model code."""
+
+from __future__ import annotations
+
+import abc
+import configparser
+import os
+
+import typing
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Union,
+)
+
+try:
+    from typing import Final
+except ImportError:  # pragma: no cover
+    from typing_extensions import Final  # type: ignore
+
+from PySide6 import QtCore
+
+import speedwagon
+
+from speedwagon.config import SettingsData, SettingsDataType
+WorkflowsSettings = Dict[str, SettingsData]
+WorkflowDataType = List[Union[str, int, bool, None]]
+__all__ = ["SettingsModel"]
+
+QtConstant = int
+
+
+class SettingsModel(QtCore.QAbstractTableModel):
+    """Settings Qt table model."""
+
+    COLUMNS: Final[int] = 2
+
+    def __init__(self, *__args) -> None:
+        """Create a new settings Qt model."""
+        super().__init__(*__args)
+        self._unmodified_data: List[Tuple[str, str]] = []
+        self._data: List[Tuple[str, str]] = []
+        self._headers = {0: "Key", 1: "Value"}
+        self.data_modified = False
+        self.dataChanged.connect(self._update_modified)
+
+    def _update_modified(self) -> None:
+        for original, current in zip(self._unmodified_data, self._data):
+            if original[1] != current[1]:
+                self.data_modified = True
+                return
+        self.data_modified = False
+
+    def data(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+            role: Optional[QtConstant] = None
+    ) -> Optional[Union[str, QtCore.QObject]]:
+        """Get role data from an index."""
+        if not index.isValid():
+            return None
+
+        if role == QtCore.Qt.ItemDataRole.DisplayRole:
+            return self._data[index.row()][index.column()]
+
+        if role == QtCore.Qt.ItemDataRole.EditRole:
+            return self._data[index.row()][index.column()]
+
+        return None
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Optional[  # pylint: disable=unused-argument
+                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+            ] = None
+    ) -> int:
+        """Return the number of settings loaded in the model."""
+        return len(self._data)
+
+    def add_setting(self, name: str, value: str) -> None:
+        """Add setting key value to the settings."""
+        self._data.append((name, value))
+        self._unmodified_data.append((name, value))
+
+    def columnCount(  # pylint: disable=invalid-name
+            self,
+            parent: Optional[  # pylint: disable=unused-argument
+                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+            ] = None
+    ) -> int:
+        """Return number of columns.
+
+        One for the heading and one for the content.
+        """
+        return self.COLUMNS
+
+    def headerData(  # pylint: disable=invalid-name
+            self,
+            index: int,
+            orientation: QtCore.Qt.Orientation,
+            role: Optional[QtConstant] = None
+    ) -> Optional[Union[str, QtCore.QObject]]:
+        """Get header data from settings."""
+        if orientation == QtCore.Qt.Orientation.Horizontal and \
+                role == QtCore.Qt.ItemDataRole.DisplayRole:
+            return self._headers.get(index, "")
+        return None
+
+    def flags(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.Qt.ItemFlag:
+        """Manage display flags for a given index."""
+        if self._headers.get(index.column(), "") == "Key":
+            return QtCore.Qt.ItemFlag.NoItemFlags
+
+        if self._headers.get(index.column(), "") == "Value":
+            return QtCore.Qt.ItemFlag.ItemIsEnabled |\
+                QtCore.Qt.ItemFlag.ItemIsEditable
+
+        return super().flags(index)
+
+    def setData(  # pylint: disable=invalid-name
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+            data: Any,
+            role: Optional[QtConstant] = None    # pylint: disable=W0613
+    ) -> bool:
+        """Set data in model."""
+        if not index.isValid():
+            return False
+        row = index.row()
+        original_data = self._data[row]
+
+        # Only update the model if the data is actually different
+        if data != original_data[1]:
+            self._data[row] = (self._data[row][0], data)
+
+            # pylint: disable=no-member
+            self.dataChanged.emit(  # type: ignore
+                index,
+                index,
+                [QtCore.Qt.ItemDataRole.EditRole]
+            )
+
+            return True
+        return False
+
+
+def build_setting_qt_model(config_file: str) -> SettingsModel:
+    """Read a configuration file and generate a SettingsModel."""
+    if not os.path.exists(config_file):
+        raise FileNotFoundError(f"No existing Configuration in ${config_file}")
+
+    config = configparser.ConfigParser()
+    config.read(config_file)
+    global_settings = config["GLOBAL"]
+    my_model = SettingsModel()
+    for key, value in global_settings.items():
+        my_model.add_setting(key, value)
+    return my_model
+
+
+def unpack_global_settings_model(
+        model: QtCore.QAbstractItemModel
+) -> SettingsData:
+    global_data: SettingsData = {}
+
+    for i in range(model.rowCount()):
+        key: str = model.index(i, 0).data()
+        value: SettingsDataType = model.index(i, 1).data()
+        global_data[key] = value
+    return global_data
+
+
+class AbsWorkflowSettingItem(abc.ABC):
+    @abc.abstractmethod
+    def column_count(self) -> int:
+        """Column Count."""
+
+    @abc.abstractmethod
+    def parent(self) -> Optional['AbsWorkflowSettingItem']:
+        """Get parent."""
+
+    @abc.abstractmethod
+    def child_number(self) -> int:
+        """Get child number."""
+
+    @abc.abstractmethod
+    def child_count(self) -> int:
+        """Get child count."""
+
+    @abc.abstractmethod
+    def child(self, number: int) -> Optional['AbsWorkflowSettingItem']:
+        """Get Child."""
+
+    @abc.abstractmethod
+    def flags(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.Qt.ItemFlag:
+        """Get flags."""
+
+    def set_data(
+            self,
+            _: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            __: Union[str, bool, int, None],
+            ___: int = QtCore.Qt.ItemDataRole.EditRole
+    ) -> bool:
+        return False
+
+
+class WorkflowSettingsItemWorkflow(AbsWorkflowSettingItem):
+    def __init__(
+            self,
+            workflow: Optional[speedwagon.Workflow] = None,
+            parent: Optional[WorkflowSettingsRoot] = None
+    ) -> None:
+        super().__init__()
+        self._workflow = workflow
+        self.parent_item = parent
+        self.child_items: List[WorkflowSettingsMetadata] = []
+
+    @property
+    def workflow(self) -> Optional[speedwagon.Workflow]:
+        return self._workflow
+
+    @workflow.setter
+    def workflow(self, value: speedwagon.Workflow) -> None:
+        self._workflow = value
+        options = value.workflow_options()
+        for option in options:
+            item = WorkflowSettingsMetadata(option, self)
+            item.value = value.get_workflow_configuration_value(option.label)
+            self.child_items.insert(0, item)
+
+    def column_count(self) -> int:
+        return 0 if self.workflow is None else 2
+
+    def child(self, number: int) -> Optional[WorkflowSettingsMetadata]:
+        if number < 0 or number >= len(self.child_items):
+            return None
+        return self.child_items[number]
+
+    def child_count(self) -> int:
+        return len(self.child_items)
+
+    def data_column(
+            self,
+            column: int,
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Union[str, bool, int, None]:
+
+        if role == QtCore.Qt.ItemDataRole.DisplayRole:
+            if column == 0:
+                if self.workflow is not None:
+                    return self.workflow.name \
+                        if self.workflow.name is not None else ''
+                return ''
+            return None
+        return None
+
+    def last_child(self) -> Optional[WorkflowSettingsMetadata]:
+        return self.child_items[-1] if self.child_items else None
+
+    def parent(self) -> Optional[WorkflowSettingsRoot]:
+        return self.parent_item
+
+    def child_number(self) -> int:
+        return self.parent_item.child_items.index(self) \
+            if self.parent_item else 0
+
+    def remove_children(self, position: int, count: int) -> bool:
+        if position < 0 or position + count > len(self.child_items):
+            return False
+        for _ in range(count):
+            self.child_items.pop(position)
+        return True
+
+    def flags(self, index: Union[
+        QtCore.QModelIndex,
+        QtCore.QPersistentModelIndex
+    ]) -> QtCore.Qt.ItemFlag:
+        return QtCore.Qt.ItemFlag.ItemIsEnabled
+
+
+class EditSettingsDataRole:
+    def __init__(self, parent: WorkflowSettingsMetadata):
+        self.parent = parent
+        self._role_methods: Dict[
+            QtCore.Qt.ItemDataRole,
+            Callable[[int], Any]
+        ] = {
+            QtCore.Qt.ItemDataRole.DisplayRole: self.display_role,
+            QtCore.Qt.ItemDataRole.EditRole: self.edit_role
+        }
+
+    def display_role(self, column: int) -> SettingsDataType:
+        label = self.parent.option.label \
+            if self.parent.option is not None \
+            else ''
+        return {
+            0: label,
+            1: self.parent.value
+        }.get(column)
+
+    def edit_role(self, column: int) -> SettingsDataType:
+        return {
+            1: self.parent.value
+        }.get(column)
+
+    def data(
+            self,
+            column: int,
+            role: QtCore.Qt.ItemDataRole
+    ) -> SettingsDataType:
+        method = self._role_methods.get(role)
+        return None if method is None else method(column)
+
+
+class WorkflowSettingsMetadata(AbsWorkflowSettingItem):
+
+    def __init__(
+            self,
+            option: Optional[speedwagon.job.AbsOutputOptionDataType] = None,
+            parent: Optional[WorkflowSettingsItemWorkflow] = None
+    ) -> None:
+        super().__init__()
+        self.value: Union[str, bool, int, None] = None
+        self.data_strategy = EditSettingsDataRole(self)
+        self.option: Optional[speedwagon.job.AbsOutputOptionDataType] = option
+        self.parent_item = parent
+        self.child_items: List[AbsWorkflowSettingItem] = []
+
+    def column_count(self) -> int:
+        return 2 if self.option else 0
+
+    def child(self, number: int) -> Optional[AbsWorkflowSettingItem]:
+        if number < 0 or number >= len(self.child_items):
+            return None
+        return self.child_items[number]
+
+    def child_count(self) -> int:
+        return len(self.child_items)
+
+    @property
+    def label(self) -> Optional[str]:
+        return None if self.option is None else self.option.label
+
+    def insert_children(self, _: int, __: int, ___: int) -> bool:
+        return False
+
+    # pylint: disable=unused-argument
+    def set_data(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            value: Union[str, bool, int, None],
+            role: int = QtCore.Qt.ItemDataRole.EditRole
+    ) -> bool:
+        if index.column() == 1:
+            self.value = value
+            return True
+        return False
+    # pylint: enable=unused-argument
+
+    def data_column(
+            self,
+            column: int,
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> SettingsDataType:
+        return self.data_strategy.data(column, QtCore.Qt.ItemDataRole(role))
+
+    def flags(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.Qt.ItemFlag:
+        if index.column() == 1:
+            return \
+                    QtCore.Qt.ItemFlag.ItemIsEnabled | \
+                    QtCore.Qt.ItemFlag.ItemIsSelectable | \
+                    QtCore.Qt.ItemFlag.ItemIsEditable
+        return QtCore.Qt.ItemFlag.ItemIsEnabled | \
+            QtCore.Qt.ItemFlag.ItemIsSelectable
+
+    def last_child(self) -> Optional[AbsWorkflowSettingItem]:
+        return self.child_items[-1] if self.child_items else None
+
+    def parent(self) -> Optional[WorkflowSettingsItemWorkflow]:
+        return self.parent_item
+
+    def child_number(self) -> int:
+        return self.parent_item.child_items.index(self) \
+            if self.parent_item \
+            else 0
+
+    def remove_children(self, _: int, __: int) -> bool:
+        return False
+
+
+class WorkflowSettingsRoot(AbsWorkflowSettingItem):
+
+    def __init__(
+            self,
+            parent: Optional['WorkflowSettingsRoot'] = None
+    ) -> None:
+        super().__init__()
+        self.item_data: WorkflowDataType = ["Property", "Value"]
+        self.workflow: Optional[speedwagon.Workflow] = None
+        self.parent_item = parent
+        self.child_items: List[WorkflowSettingsItemWorkflow] = []
+
+    def column_count(self) -> int:
+        return len(self.item_data)
+
+    def child(self, number: int) -> Optional[WorkflowSettingsItemWorkflow]:
+        if number < 0 or number >= len(self.child_items):
+            return None
+        return self.child_items[number]
+
+    def child_count(self) -> int:
+        return len(self.child_items)
+
+    def insert_children(self, position: int, count: int, _: int) -> bool:
+        if position < 0 or position > len(self.child_items):
+            return False
+
+        for _ in range(count):
+            item = WorkflowSettingsItemWorkflow(None, self)
+            self.child_items.insert(position, item)
+        return True
+
+    def data_column(
+            self,
+            column: int,
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Union[str, bool, int, None]:
+        if role == QtCore.Qt.ItemDataRole.DisplayRole:
+            if column < 0 or column >= len(self.item_data):
+                return None
+            return self.item_data[column]
+        return None
+
+    def flags(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.Qt.ItemFlag:
+        return QtCore.Qt.ItemFlag.NoItemFlags
+
+    def data(self, column: int):
+        if column < 0 or column >= len(self.item_data):
+            return None
+        return self.item_data[column]
+
+    def last_child(self) -> Optional['WorkflowSettingsItemWorkflow']:
+        return self.child_items[-1] if self.child_items else None
+
+    def parent(self) -> Optional[AbsWorkflowSettingItem]:
+        return self.parent_item
+
+    def child_number(self) -> int:
+        return typing.cast(
+            List[AbsWorkflowSettingItem],
+            self.parent_item.child_items
+        ).index(self) if self.parent_item else 0
+
+    def remove_children(self, position: int, count: int) -> bool:
+        if position < 0 or position + count > len(self.child_items):
+            return False
+        for _ in range(count):
+            self.child_items.pop(position)
+        return True
+
+
+class WorkflowSettingsModel(QtCore.QAbstractItemModel):
+    _headers: Dict[int, str] = {
+        0: "Property",
+        1: "Value",
+    }
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        super().__init__(parent)
+        self.root_item = WorkflowSettingsRoot()
+        self._unmodified_data = str(self.results())
+
+    def columnCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> int:
+        if parent.isValid():
+            item = self.get_item(parent)
+            return item.column_count()
+        return self.root_item.column_count()
+
+    def flags(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.Qt.ItemFlag:
+        return (
+            self.get_item(index).flags(index)
+            if index.isValid()
+            else QtCore.Qt.ItemFlag.NoItemFlags
+        )
+
+    def setData(  # pylint: disable=invalid-name
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            value: Any,
+            role: int = QtCore.Qt.ItemDataRole.EditRole
+    ) -> bool:
+        if role != QtCore.Qt.ItemDataRole.EditRole:
+            return False
+        results = self.get_item(index).set_data(index, value, role)
+        if results is True:
+            self.dataChanged.emit(
+                index,
+                index,
+                [
+                    QtCore.Qt.ItemDataRole.EditRole,
+                    QtCore.Qt.ItemDataRole.DisplayRole
+                ]
+            )
+        return results
+
+    def headerData(  # pylint: disable=invalid-name
+            self,
+            section: int, orientation: QtCore.Qt.Orientation,
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Any:
+        if (
+                orientation == QtCore.Qt.Orientation.Horizontal and
+                role == QtCore.Qt.ItemDataRole.DisplayRole
+        ):
+            return self.root_item.data(section)
+        if all([
+            orientation == QtCore.Qt.Orientation.Vertical,
+            role == QtCore.Qt.ItemDataRole.DisplayRole,
+            section < len(self.root_item.child_items)
+        ]):
+            return section + 1
+        return None
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> int:
+        if parent.isValid() and parent.column() > 0:
+            return 0
+        parent_item = \
+            typing.cast(Optional[WorkflowSettingsRoot], self.get_item(parent))
+
+        if not parent_item:
+            return 0
+        return parent_item.child_count()
+
+    def index(
+            self,
+            row: int,
+            column: int = 0,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> QtCore.QModelIndex:
+
+        if parent.isValid() and parent.column() != 0:
+            return QtCore.QModelIndex()
+        parent_item = self.get_item(parent)
+        if not parent_item:
+            return QtCore.QModelIndex()
+        child_item = parent_item.child(row)
+        if child_item:
+            if column >= child_item.column_count():
+                return QtCore.QModelIndex()
+            return self.createIndex(row, column, child_item)
+        return QtCore.QModelIndex()
+
+    def add_workflow(self, workflow: speedwagon.Workflow) -> None:
+        self.root_item.insert_children(
+            self.root_item.child_count(),
+            1,
+            self.root_item.column_count()
+        )
+        workflow_item: Optional[WorkflowSettingsItemWorkflow] = \
+            self.root_item.last_child()
+
+        if workflow_item is None:
+            return
+        workflow_item.workflow = workflow
+
+    def clear(self) -> None:
+        self.root_item.remove_children(0, self.rowCount())
+
+    def remove_workflow(self, workflow: speedwagon.Workflow) -> None:
+        for i in range(self.root_item.child_count()):
+            child_item = self.root_item.child(i)
+            if child_item is None:
+                continue
+            if child_item.workflow == workflow:
+                self.root_item.remove_children(i, 1)
+                break
+        else:
+            raise ValueError(f"{workflow} not in")
+
+    def data(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Union[str, bool, int, None]:
+        if not index.isValid():
+            return None
+        item = typing.cast(WorkflowSettingsRoot, self.get_item(index))
+        return item.data_column(index.column(), role)
+
+    @typing.overload
+    def parent(self) -> QtCore.QObject:
+        ...
+
+    @typing.overload
+    def parent(
+            self,
+            child: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.QModelIndex:
+        ...
+
+    def parent(
+            self,
+            child: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+            ] = QtCore.QModelIndex()
+    ) -> Union[QtCore.QModelIndex, QtCore.QObject]:
+        if not child.isValid():
+            return QtCore.QModelIndex()
+        child_item = self.get_item(child)
+        parent_item: Optional[AbsWorkflowSettingItem] = \
+            child_item.parent() if child_item else None
+        if parent_item == self.root_item or not parent_item:
+            return QtCore.QModelIndex()
+        return self.createIndex(parent_item.child_number(), 0, parent_item)
+
+    def get_item(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+            ] = QtCore.QModelIndex()
+    ) -> AbsWorkflowSettingItem:
+        if index.isValid():
+            item = typing.cast(WorkflowSettingsRoot, index.internalPointer())
+            if item:
+                return item
+
+        return self.root_item
+
+    def results(self) -> WorkflowsSettings:
+        results: WorkflowsSettings = {}
+        for workflow_row_id in range(self.rowCount()):
+            workflow_index = self.index(workflow_row_id, 0)
+            item = typing.cast(
+                WorkflowSettingsItemWorkflow,
+                self.get_item(workflow_index)
+            )
+            if item.workflow is None:
+                continue
+
+            settings: SettingsData = {
+                str(self.data(self.index(row, 0, parent=workflow_index))):
+                    self.data(self.index(row, 1, parent=workflow_index))
+                for row in range(self.rowCount(parent=workflow_index))
+            }
+            workflow_name = item.workflow.name \
+                if item.workflow.name is not None else ''
+            results[workflow_name] = settings
+        return results
+
+    def modified(self) -> bool:
+        return str(self.results()) != self._unmodified_data
+
+    def reset_modified(self) -> None:
+        self._unmodified_data = str(self.results())
+
+    @property
+    def workflows(self) -> Iterable[WorkflowSettingsItemWorkflow]:
+        return [
+            typing.cast(
+                WorkflowSettingsItemWorkflow, self.get_item(self.index(i, 0))
+            )
+            for i in range(self.rowCount())
+        ]
diff --git a/speedwagon/frontend/qtwidgets/models/tabs.py b/speedwagon/frontend/qtwidgets/models/tabs.py
new file mode 100644
index 000000000..872684c19
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/tabs.py
@@ -0,0 +1,604 @@
+"""Tabs model code."""
+
+from __future__ import annotations
+
+import abc
+from typing import (
+    Optional,
+    Type,
+    Union,
+    overload,
+    Dict,
+    List,
+    cast,
+    Iterator,
+    Any,
+    TYPE_CHECKING
+)
+
+from PySide6 import QtCore, QtGui
+
+import speedwagon
+from .common import WorkflowItem, WorkflowClassRole
+if TYPE_CHECKING:
+    from speedwagon.config import AbsTabsConfigDataManagement
+
+__all__ = ["TabsTreeModel", "TabStandardItem", "TabProxyModel"]
+
+
+class TabsTreeModel(QtCore.QAbstractItemModel):
+    """Tree model containing user defined tabs and the containing workflows."""
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new tab tree model.
+
+        Args:
+            parent: Parent widget to control widget lifespan
+        """
+        super().__init__(parent)
+        self.root_item = QtGui.QStandardItem()
+        self._starting_rows = self.rowCount()
+
+    def append_workflow_tab(
+            self,
+            name: str,
+            workflows: Optional[List[Type[speedwagon.Workflow]]] = None
+    ) -> None:
+        """Add a new tab."""
+        new_tab = TabStandardItem(name, workflows or [])
+        self.root_item.appendRow(new_tab)
+        self.modelReset.emit()
+
+    @property
+    def data_modified(self) -> bool:
+        """Get if the data contained has changed since last reset."""
+        if self._starting_rows != self.rowCount():
+            return True
+        return any(tab.data_modified for tab in self.tabs)
+
+    @property
+    def tabs(self) -> Iterator[TabStandardItem]:
+        """Get all the tbs in the tree model."""
+        for row_id in range(self.rowCount()):
+            yield cast(TabStandardItem, self.get_item(self.index(row_id, 0)))
+
+    def columnCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> int:
+        """Get column count."""
+        return 2
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> int:
+        """Get the number of tabs in the model."""
+        if parent.isValid() and parent.column() > 2:
+            return 0
+        parent_item = self.get_item(parent)
+        return parent_item.rowCount() if parent_item else 0
+
+    def index(
+            self,
+            row: int,
+            column: int = 0,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> QtCore.QModelIndex:
+        """Get index."""
+        if not self.hasIndex(row, column, parent):
+            return QtCore.QModelIndex()
+        child_item = self.get_item(parent).child(row)
+        if child_item:
+            return self.createIndex(row, column, child_item)
+        return QtCore.QModelIndex()
+
+    @overload
+    def parent(self) -> QtCore.QObject:
+        ...
+
+    @overload
+    def parent(
+            self,
+            child: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.QModelIndex:
+        ...
+
+    def parent(
+            self,
+            child: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+            ] = QtCore.QModelIndex()
+    ) -> Union[QtCore.QModelIndex, QtCore.QObject]:
+        """Get the parent object or object."""
+        if not child.isValid():
+            return QtCore.QModelIndex()
+        child_item = self.get_item(child)
+        parent_item = child_item.parent()
+        if parent_item == self.root_item or not parent_item:
+            return QtCore.QModelIndex()
+        return self.createIndex(parent_item.row(), 0, parent_item)
+
+    @staticmethod
+    def get_workflow_item_data(
+            item: WorkflowItem,
+            column: int,
+            role: int
+    ) -> Any:
+        """Get the right data for the data model for a given role."""
+        if role == QtGui.Qt.ItemDataRole.DisplayRole:
+            if column == 0:
+                return str(item.name or "")
+            if column == 1:
+                return "" if item.workflow is None \
+                    else str(item.workflow.description or "")
+        return (
+            item.workflow if role == WorkflowClassRole
+            else None
+        )
+
+    def data(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex],
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Any:
+        """Get data."""
+        if not index.isValid():
+            return None
+        item = self.get_item(index)
+        if isinstance(item, TabStandardItem):
+            return item.data(role=role) if index.column() == 0 else None
+        if isinstance(item, WorkflowItem):
+            return self.get_workflow_item_data(item, index.column(), role)
+        return None
+
+    def get_item(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> Union[QtGui.QStandardItem, TabStandardItem, WorkflowItem]:
+        """Get item at a given index."""
+        if index.isValid():
+            item = index.internalPointer()
+            if item:
+                return cast(TabStandardItem, item)
+        return self.root_item
+
+    def headerData(  # pylint: disable=invalid-name
+            self,
+            section: int,
+            orientation: QtCore.Qt.Orientation,
+            role: int = QtGui.Qt.ItemDataRole.DisplayRole) -> Any:
+        """Get header data."""
+        if (
+                orientation == QtCore.Qt.Orientation.Horizontal
+                and role == QtGui.Qt.ItemDataRole.DisplayRole
+        ):
+            if section == 0:
+                return "Name"
+            if section == 1:
+                return "Description"
+        return super().headerData(section, orientation, role)
+
+    def get_tab(self, tab_name: str) -> Optional[TabStandardItem]:
+        """Get a tab of a given name."""
+        for i in range(self.rowCount()):
+            index = self.index(i, 0)
+            if self.data(index) == tab_name:
+                return cast(TabStandardItem, self.get_item(index))
+        return None
+
+    def append_workflow_to_tab(
+            self,
+            tab_name: str,
+            workflow: Type[speedwagon.Workflow]
+    ) -> None:
+        """Append a workflow to a tab with a given name."""
+        tab = self.get_tab(tab_name)
+        if tab is None:
+            raise ValueError(f"No tab named {tab_name}")
+        tab.append_workflow(workflow)
+
+    @property
+    def tab_names(self) -> List[str]:
+        """Get the tabs of the tabs in the model."""
+        return [
+            self.data(self.index(i, 0)) for i in range(self.rowCount())
+        ]
+
+    def removeRow(  # pylint: disable=invalid-name
+            self,
+            row: int,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> bool:
+        """Remove row from model."""
+        if parent.isValid():
+            item = self.get_item(parent)
+            if isinstance(item, TabStandardItem):
+                item.removeRow(row)
+                return True
+            return False
+        original_row_count = self.root_item.rowCount()
+        self.root_item.removeRow(row)
+        resulting_row_count = self.root_item.rowCount()
+        return resulting_row_count < original_row_count
+
+    def reset_modified(self) -> None:
+        """Reset the data so that appears unmodified."""
+        self._starting_rows = self.rowCount()
+        for tab in self.tabs:
+            tab.reset_modified()
+        self.dataChanged.emit(self.root_item.index(), [])
+
+    def tab_information(self) -> List[speedwagon.config.CustomTabData]:
+        """Get the custom tab data for all the tabs in the model."""
+        return [
+            speedwagon.config.CustomTabData(
+                tab.name,
+                [work.name or "" for work in tab.workflows]
+            ) for tab in self.tabs
+        ]
+
+    def __len__(self) -> int:
+        """Get the size in terms of rows."""
+        return self.rowCount()
+
+    def __getitem__(self, item: int) -> TabStandardItem:
+        """Get item via a numerical index."""
+        for i, tab in enumerate(self.tabs):
+            if i == item:
+                return tab
+        raise IndexError(f'{item} not found in model.')
+
+    def clear(self) -> None:
+        """Clear all the data in the model."""
+        for row_id in range(self.rowCount()):
+            self.removeRow(row_id)
+
+    def setData(  # pylint: disable=invalid-name
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            value: Any,
+            role: int = QtGui.Qt.ItemDataRole.DisplayRole
+    ) -> bool:
+        """Set data."""
+        if role == WorkflowClassRole:
+            cast(WorkflowItem,  self.get_item(index)).workflow = value
+            return True
+        return super().setData(index, value, role)
+
+
+class TabProxyModel(QtCore.QAbstractProxyModel):
+    """Proxy model for tab tree model data.
+
+    For limiting the scope to a single tab.
+    """
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new tab proxy model.
+
+        Args:
+            parent: Parent widget to control widget lifespan
+        """
+        super().__init__(parent)
+        self.source_tab: Optional[str] = None
+
+    def add_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add workflow to list."""
+        if self.source_tab is None:
+            raise RuntimeError("source_tab not set")
+        base_index = self.get_source_tab_index(self.source_tab)
+        if not base_index.isValid():
+            return
+        item = base_index.internalPointer()
+        if isinstance(item, TabStandardItem):
+            if workflow in item:
+                return
+            self.beginResetModel()
+            item.append_workflow(workflow)
+            self.endResetModel()
+
+    def remove_workflow(self, workflow: Type[speedwagon.Workflow]):
+        """Remove workflow from list."""
+        if self.source_tab is None:
+            raise RuntimeError("source_tab not set")
+        source_model = self.sourceModel()
+        self.beginResetModel()
+        base_index = self.get_source_tab_index(self.source_tab)
+        for row_id in reversed(
+                range(source_model.rowCount(parent=base_index))
+        ):
+            if source_model.data(
+                    source_model.index(row_id, 0, parent=base_index),
+                    role=WorkflowClassRole
+            ) == workflow:
+                self.beginRemoveRows(base_index, row_id, row_id + 1)
+                source_model.removeRow(row_id, parent=base_index)
+                self.endRemoveRows()
+
+    def sort(
+            self,
+            column: int,
+            order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder
+    ) -> None:
+        """Sort data."""
+        if self.source_tab is None:
+            return
+        base_index = self.get_source_tab_index(self.source_tab)
+        if not base_index.isValid():
+            return
+        item = base_index.internalPointer()
+        if isinstance(item, TabStandardItem):
+            item.sortChildren(column, order)
+        super().sort(column, order)
+
+    def set_source_tab(self, tab_name: str) -> None:
+        """Set the source tab from the source model to use."""
+        self.beginResetModel()
+        self.source_tab = tab_name
+        self.endResetModel()
+
+    def get_source_tab_index(self, tab_name: str) -> QtCore.QModelIndex:
+        """Get the index of a tab with a given name."""
+        source_model = self.sourceModel()
+        if source_model is None:
+            return QtCore.QModelIndex()
+
+        for row_id in range(source_model.rowCount()):
+            index = source_model.index(row_id, 0)
+            if source_model.data(
+                    index,
+                    QtCore.Qt.ItemDataRole.DisplayRole
+            ) == tab_name:
+                return index
+        return QtCore.QModelIndex()
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> int:
+        """Get the number of workflows in the list."""
+        if self.source_tab is None:
+            return 0
+        index = self.get_source_tab_index(self.source_tab)
+        source_model = self.sourceModel()
+        if source_model is None:
+            return 0
+        return source_model.rowCount(index) if index.isValid() else 0
+
+    def mapFromSource(  # pylint: disable=invalid-name
+            self,
+            sourceIndex: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.QModelIndex:
+        """Map from source index."""
+        return (
+            self.index(row=sourceIndex.row(), column=0)
+            if sourceIndex.isValid()
+            else QtCore.QModelIndex()
+        )
+
+    def columnCount(  # pylint: disable=invalid-name
+            self,
+            parent: Optional[  # pylint: disable=unused-argument
+                Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+            ] = None
+    ) -> int:
+        """Get column count."""
+        return 1
+
+    def index(
+            self,
+            row: int,
+            column: int = 0,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> QtCore.QModelIndex:
+        """Get index."""
+        if parent.isValid():
+            return QtCore.QModelIndex()
+        return self.createIndex(row, column)
+
+    def mapToSource(  # pylint: disable=invalid-name
+            self,
+            proxyIndex: Union[  # pylint: disable=invalid-name
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.QModelIndex:
+        """Map to source index."""
+        if self.source_tab is None:
+            return QtCore.QModelIndex()
+        base_index = self.get_source_tab_index(self.source_tab)
+        if not proxyIndex.isValid() and base_index is not None:
+            return QtCore.QModelIndex()
+
+        source_model = self.sourceModel()
+        proxy_index = proxyIndex
+        return source_model.index(
+                row=proxy_index.row(),
+                column=proxy_index.column(),
+                parent=base_index
+            )
+
+    @overload
+    def parent(self) -> QtCore.QObject:
+        ...
+
+    @overload
+    def parent(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.QModelIndex:
+        ...
+
+    def parent(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+                None
+            ] = None
+    ) -> Union[QtCore.QModelIndex, QtCore.QObject]:
+        """Get the parent object or object."""
+        return QtCore.QObject() if index is None else QtCore.QModelIndex()
+
+    def get_tab_index(self) -> QtCore.QModelIndex:
+        """Get cyrrent tab index."""
+        if self.source_tab is None:
+            raise RuntimeError('source model not set')
+        return self.get_source_tab_index(self.source_tab)
+
+
+class AbsLoadTabDataModelStrategy(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def load(self, model: TabsTreeModel) -> None:
+        """Load data."""
+
+
+class TabDataModelYAMLLoader(AbsLoadTabDataModelStrategy):
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.yml_file: Optional[str] = None
+
+    @staticmethod
+    def prep_data(
+            data_load_strategy: AbsTabsConfigDataManagement
+    ) -> Dict[str, List[Type[speedwagon.Workflow]]]:
+        all_workflows = speedwagon.job.available_workflows()
+
+        sorted_workflows = \
+            sorted(list(all_workflows.values()), key=lambda item: item.name)
+
+        workflow_tabs_data: Dict[str, List[Type[speedwagon.Workflow]]] = {
+            "All": sorted_workflows
+        }
+        for tab_data in data_load_strategy.data():
+            tab_workflows = []
+            for workflow_name in tab_data.workflow_names:
+                if workflow_name in all_workflows:
+                    workflow_klass = all_workflows[workflow_name]
+                    tab_workflows.append(workflow_klass)
+            workflow_tabs_data[tab_data.tab_name] = tab_workflows
+
+        return workflow_tabs_data
+
+    def load(self, model: TabsTreeModel) -> None:
+        if self.yml_file is None:
+            return
+        data = self.prep_data(
+            data_load_strategy=speedwagon.config.CustomTabsYamlConfig(
+                self.yml_file
+            )
+        )
+        for tab_name, workflows in data.items():
+            model.append_workflow_tab(tab_name, workflows)
+        model.reset_modified()
+        model.modelReset.emit()
+
+
+class TabDataModelConfigLoader(TabDataModelYAMLLoader):
+    def __init__(self) -> None:
+        super().__init__()
+        config_strategy = speedwagon.config.StandardConfigFileLocator()
+        self.yml_file = config_strategy.get_tabs_file()
+
+
+class TabStandardItem(QtGui.QStandardItem):
+    """QStandardItem that contains tab workflows data."""
+
+    def __init__(
+            self,
+            name: Optional[str] = None,
+            workflows: Optional[List[Type[speedwagon.Workflow]]] = None
+    ) -> None:
+        """Create a new TabStandardItem."""
+        super().__init__()
+        if name:
+            self.setText(name)
+        self._unmodified_workflows: List[WorkflowItem] = []
+        self.reset_modified()
+        for workflow in (workflows or []):
+            self.append_workflow(workflow)
+
+    @property
+    def workflows(self) -> list[WorkflowItem]:
+        """Get the list of workflows in the tab."""
+        return [
+            cast(WorkflowItem, self.child(row_id, 0))
+            for row_id in range(self.rowCount())
+        ]
+
+    @property
+    def data_modified(self) -> bool:
+        """Get if the data contained has changed since last reset."""
+        if len(self.workflows) != len(self._unmodified_workflows):
+            return True
+
+        return any(
+            current.workflow != unmodified.workflow
+            for current, unmodified in zip(
+                self.workflows, self._unmodified_workflows
+            )
+        )
+
+    def reset_modified(self) -> None:
+        """Reset the data so that appears unmodified."""
+        self._unmodified_workflows = self.workflows.copy()
+
+    @property
+    def name(self) -> str:
+        """Get the name used by the tab."""
+        return self.text()
+
+    def append_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add a workflow to the list."""
+        if workflow not in self:
+            self.appendRow(WorkflowItem(workflow))
+            self.emitDataChanged()
+
+    def __contains__(self, workflow: Type[speedwagon.Workflow]) -> bool:
+        """Check if workflow in already in item."""
+        for row_id in range(self.rowCount()):
+            item = cast(WorkflowItem, self.child(row_id, 0))
+            if item.workflow == workflow:
+                return True
+        return False
+
+    def remove_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Remove workflow from list."""
+        def _find_row_with_matching_workflow() -> Optional[int]:
+            for row_id in range(self.rowCount()):
+                item = cast(WorkflowItem, self.child(row_id, 0))
+                if item.workflow == workflow:
+                    return row_id
+            return None
+        while True:
+            row_id_to_delete = _find_row_with_matching_workflow()
+            if row_id_to_delete is None:
+                break
+            self.removeRow(row_id_to_delete)
diff --git a/speedwagon/frontend/qtwidgets/models/workflows.py b/speedwagon/frontend/qtwidgets/models/workflows.py
new file mode 100644
index 000000000..bd8e5a0d6
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/models/workflows.py
@@ -0,0 +1,295 @@
+"""Workflows model code."""
+
+from __future__ import annotations
+
+import typing
+from typing import Type, Optional, List, cast, Union, overload, Any
+
+from PySide6 import QtCore
+
+import speedwagon
+from .common import AbsWorkflowItemData, WorkflowItemData, WorkflowClassRole
+from .tabs import TabStandardItem, TabsTreeModel
+
+__all__ = [
+    "AbsWorkflowList",
+    "WorkflowList",
+    "WorkflowListProxyModel",
+]
+
+
+class AbsWorkflowList(   # pylint: disable=too-few-public-methods
+    QtCore.QAbstractListModel
+):
+    """Abstract workflow list model."""
+
+    def add_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add a workflow to the model."""
+        raise NotImplementedError
+
+
+class WorkflowList(AbsWorkflowList):
+    """List of workflow uninitiated classes."""
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new workflow list model.
+
+        Args:
+            parent: Parent widget to control widget lifespan
+        """
+        super().__init__(parent)
+        self._workflows: List[Type[speedwagon.Workflow]] = []
+        self.data_strategy: AbsWorkflowItemData = WorkflowItemData()
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> int:
+        """Get the number of workflows in the list."""
+        return len(self._workflows)
+
+    def add_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add workflow to list."""
+        self._workflows.append(workflow)
+        self.dataChanged.emit(len(self._workflows), len(self._workflows), 0)
+
+    def data(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            role: int = QtCore.Qt.ItemDataRole.DisplayRole
+    ) -> Any:
+        """Get data."""
+        return (
+            self.data_strategy.data(self._workflows[index.row()], role)
+            if index.isValid() else None
+        )
+
+    def insertRow(  # pylint: disable=invalid-name
+            self,
+            row: int,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> bool:
+        """Insert row with a Null Workflow."""
+        self._workflows.insert(row, speedwagon.job.NullWorkflow)
+        return super().insertRow(row, parent)
+
+    def removeRow(  # pylint: disable=invalid-name
+            self,
+            row: int,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()) -> bool:
+        """Remove row from model."""
+        if row > len(self._workflows):
+            return False
+        self._workflows.pop(row)
+        return True
+
+    def setData(  # pylint: disable=invalid-name
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ],
+            value: Any,
+            role: int = QtCore.Qt.ItemDataRole.EditRole
+    ) -> bool:
+        """Set data."""
+        if not index.isValid():
+            return False
+        if role in [
+            QtCore.Qt.ItemDataRole.EditRole,
+            WorkflowClassRole,
+        ]:
+            self._workflows[index.row()] = value
+            return True
+        return super().setData(index, value, role)
+
+
+class WorkflowListProxyModel(QtCore.QAbstractProxyModel, AbsWorkflowList):
+    """Proxy model for workflows.
+
+    Uses the tab index of tree model to proxy list model data.
+    """
+
+    def __init__(self, parent: Optional[QtCore.QObject] = None) -> None:
+        """Create a new workflow list proxy model.
+
+        Args:
+            parent: Parent widget to control widget lifespan
+        """
+        super().__init__(parent)
+        self._tab_index = 0
+        self._current_tab_item: Optional[TabStandardItem] = None
+
+    def set_tab_index(self, index: int) -> None:
+        """Set the current tab used by tab's index."""
+        source_model = typing.cast(
+            Optional[TabsTreeModel],
+            self.sourceModel()
+        )
+        if source_model is None:
+            return
+
+        self.beginResetModel()
+        self._tab_index = index
+        item_index = source_model.index(self._tab_index, 0)
+        self._current_tab_item = \
+            typing.cast(TabStandardItem, source_model.get_item(item_index))
+        self.endResetModel()
+
+    def rowCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> int:
+        """Get the number of workflows in the list."""
+        source_model = typing.cast(
+            Optional[TabsTreeModel],
+            self.sourceModel()
+        )
+        if source_model is None:
+            return 0
+        return source_model.rowCount(source_model.index(self._tab_index, 0))
+
+    def columnCount(  # pylint: disable=invalid-name
+            self,
+            parent: Union[  # pylint: disable=unused-argument
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex] = QtCore.QModelIndex()
+    ) -> int:
+        """Get column count."""
+        return 0 if self.sourceModel() is None else 1
+
+    def index(
+            self,
+            row: int,
+            column: int = 0,
+            parent: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ] = QtCore.QModelIndex()
+    ) -> QtCore.QModelIndex:
+        """Get index."""
+        if parent.isValid():
+            return QtCore.QModelIndex()
+        return self.createIndex(row, column)
+
+    def mapFromSource(  # pylint: disable=invalid-name
+            self,
+            source_index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.QModelIndex:
+        """Map from source index."""
+        return (
+            self.index(row=source_index.row(), column=0)
+            if source_index.isValid()
+            else QtCore.QModelIndex()
+        )
+
+    def mapToSource(  # pylint: disable=invalid-name
+            self,
+            proxy_index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex
+            ]
+    ) -> QtCore.QModelIndex:
+        """Map to source index."""
+        source_model = typing.cast(Optional[TabsTreeModel], self.sourceModel())
+        if not proxy_index.isValid() or source_model is None:
+            return QtCore.QModelIndex()
+
+        return source_model.index(
+            row=proxy_index.row(),
+            column=proxy_index.column(),
+            parent=source_model.index(self._tab_index, 0)
+        )
+
+    @overload
+    def parent(self) -> QtCore.QObject:
+        ...
+
+    @overload
+    def parent(
+            self,
+            index: Union[QtCore.QModelIndex, QtCore.QPersistentModelIndex]
+    ) -> QtCore.QModelIndex:
+        ...
+
+    def parent(
+            self,
+            index: Union[
+                QtCore.QModelIndex,
+                QtCore.QPersistentModelIndex,
+                None
+            ] = None
+    ) -> Union[QtCore.QModelIndex, QtCore.QObject]:
+        """Get the parent object or object."""
+        return QtCore.QObject() if index is None else QtCore.QModelIndex()
+
+    def set_by_name(self, name: str) -> None:
+        """Get active tab using the name of the tab."""
+        source_model = cast(Optional[TabsTreeModel], self.sourceModel())
+        if source_model is None:
+            return
+
+        for i in range(source_model.rowCount()):
+            item = cast(
+                TabStandardItem,
+                source_model.get_item(source_model.index(row=i, column=0))
+            )
+            if item.name == name:
+                self.set_tab_index(i)
+                break
+        else:
+            raise ValueError(f"Parent model does not contain tab {name}")
+
+    @property
+    def current_tab_name(self) -> Optional[str]:
+        """Get current tab name."""
+        return (
+            None if self._current_tab_item is None
+            else self._current_tab_item.name
+        )
+
+    def add_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add workflow to list."""
+        if self._current_tab_item is None:
+            raise RuntimeError("model not set")
+        start_index = self._current_tab_item.index()
+        self.beginInsertRows(
+            start_index,
+            self._current_tab_item.rowCount(),
+            self._current_tab_item.rowCount()
+        )
+        self._current_tab_item.append_workflow(workflow)
+        source_model = self.sourceModel()
+        source_model.dataChanged.emit(
+            source_model.index(self._tab_index, 0),
+            source_model.rowCount()
+        )
+        self.endInsertRows()
+
+    def remove_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Remove workflow from list."""
+        if self._current_tab_item is None:
+            raise RuntimeError("model not set")
+        self.beginRemoveRows(
+            self._current_tab_item.index(),
+            0,
+            self._current_tab_item.rowCount()
+        )
+        self._current_tab_item.remove_workflow(workflow)
+        self.endRemoveRows()
diff --git a/speedwagon/frontend/qtwidgets/runners.py b/speedwagon/frontend/qtwidgets/runners.py
index a5e896a20..83f073ce3 100644
--- a/speedwagon/frontend/qtwidgets/runners.py
+++ b/speedwagon/frontend/qtwidgets/runners.py
@@ -4,136 +4,14 @@
 
 import abc
 import logging
-import tempfile
-import typing
-import warnings
-from types import TracebackType
-from typing import Optional, Type, Dict, Any, Callable
-import contextlib
+from typing import Optional
 
 from PySide6 import QtWidgets, QtCore
 
-import speedwagon
-from speedwagon import frontend
 from speedwagon import runner_strategies
 from speedwagon.frontend import qtwidgets
 from speedwagon.job import AbsWorkflow
 
-
-class QtDialogProgress(frontend.reporter.RunnerDisplay):
-    """Qt based dialog box showing progress."""
-
-    def __init__(
-            self,
-            parent: typing.Optional[QtWidgets.QWidget] = None
-    ) -> None:
-        """Create a new Qt based progress dialog box."""
-        super().__init__()
-        self.dialog = qtwidgets.dialog.WorkProgressBar(parent=parent)
-        self.dialog.setMaximum(0)
-        self.dialog.setValue(0)
-
-    @property
-    def details(self) -> str:
-        """Get dialog details."""
-        return self.dialog.labelText()
-
-    @details.setter
-    def details(self, value: str) -> None:
-        if self._details == value:
-            return
-
-        self._details = value
-        self.dialog.setLabelText(value)
-        QtWidgets.QApplication.processEvents()
-
-    @property
-    def user_canceled(self) -> bool:
-        """Get status of job cancellation."""
-        return self.dialog.wasCanceled()
-
-    @property
-    def current_task_progress(self) -> typing.Optional[int]:
-        """Get the current task number."""
-        return super().current_task_progress
-
-    @current_task_progress.setter
-    def current_task_progress(self, value: typing.Optional[int]) -> None:
-        self._current_task_progress = value
-        dialog_value = value or 0
-        self.dialog.setValue(dialog_value)
-
-    @property
-    def total_tasks_amount(self) -> typing.Optional[int]:
-        """Get the total estimated tasks."""
-        return super().total_tasks_amount
-
-    @total_tasks_amount.setter
-    def total_tasks_amount(self, value: typing.Optional[int]) -> None:
-        self._total_tasks_amount = value
-        if value is None:
-            self.dialog.setMaximum(0)
-            return
-
-        self.dialog.setMaximum(value)
-
-    @property
-    def title(self) -> str:
-        """Get the window title."""
-        return self.dialog.windowTitle()
-
-    @title.setter
-    def title(self, value: str) -> None:
-        self.dialog.setWindowTitle(value)
-
-    def refresh(self) -> None:
-        """Process Qt events."""
-        QtWidgets.QApplication.processEvents()
-
-        self.current_task_progress = self._current_task_progress
-        if (
-            self.task_runner is not None
-            and self.task_runner.current_task is not None
-        ):
-            self._update_window_task_info(self.task_runner.current_task)
-        if self.task_scheduler is not None:
-            self._update_progress(self.task_scheduler)
-        QtWidgets.QApplication.processEvents()
-
-    def __enter__(self) -> "QtDialogProgress":
-        """Show dialog box."""
-        self.dialog.show()
-        return self
-
-    def __exit__(self, __exc_type: Optional[Type[BaseException]],
-                 __exc_value: Optional[BaseException],
-                 __traceback: Optional[TracebackType]):
-        """Clean up dialog."""
-        self.dialog.accept()
-        self.close()
-        return super().__exit__(__exc_type, __exc_value, __traceback)
-
-    def close(self) -> None:
-        """Close dialog box."""
-        self.dialog.close()
-
-    def _update_window_task_info(
-            self,
-            current_task: speedwagon.tasks.Subtask
-    ) -> None:
-        self.details = "Processing" \
-            if current_task.name is None \
-            else current_task.name
-
-    def _update_progress(
-            self,
-            task_scheduler:
-            runner_strategies.TaskScheduler
-    ) -> None:
-        self.total_tasks_amount = task_scheduler.total_tasks
-        self.current_task_progress = task_scheduler.current_task_progress
-
-
 USER_ABORTED_MESSAGE = "User Aborted"
 
 
@@ -150,84 +28,6 @@ def run(self, parent: QtWidgets.QWidget, job: AbsWorkflow, options: dict,
         """Run the workflow."""
 
 
-class QtRunner(speedwagon.runner.AbsRunner2):
-    """Job runner for Qt Widgets."""
-
-    def __init__(self,
-                 parent: Optional[QtWidgets.QWidget] = None) -> None:
-        """Create a new runner."""
-        self.parent = parent
-
-    @staticmethod
-    def update_progress(
-            runner: WorkRunnerExternal3,
-            current: int,
-            total: int) -> None:
-        """Update the current job progress."""
-        if runner.dialog is None:
-            return
-
-        dialog_box = runner.dialog
-        if total != dialog_box.maximum():
-            dialog_box.setMaximum(total)
-        if current != dialog_box.value():
-            dialog_box.setValue(current)
-
-        if current == total:
-            dialog_box.accept()
-
-    def request_more_info(
-            self,
-            workflow: speedwagon.job.Workflow,
-            options: Dict[str, Any],
-            pretask_results: typing.List[speedwagon.tasks.Result]
-    ) -> Dict[str, Any]:
-        """Request more information from the user."""
-        if self.parent is not None and \
-                hasattr(workflow, "get_additional_info"):
-            return workflow.get_additional_info(
-                self.parent, options, pretask_results.copy()
-            )
-        return {}
-
-    def run(self,
-            job: AbsWorkflow,
-            options: typing.Dict[str, Any],
-            logger: Optional[logging.Logger] = None,
-            completion_callback=None
-            ) -> None:
-        """Execute run."""
-        with tempfile.TemporaryDirectory() as build_dir:
-            task_scheduler = \
-                speedwagon.runner_strategies.TaskScheduler(
-                    working_directory=build_dir
-                )
-
-            task_scheduler.reporter = QtDialogProgress(parent=self.parent)
-
-            task_scheduler.logger = logger or logging.getLogger(__name__)
-
-            if isinstance(job, speedwagon.job.Workflow):
-                self.run_abs_workflow(
-                    task_scheduler=task_scheduler,
-                    job=job,
-                    options=options,
-                    logger=logger
-                )
-
-    def run_abs_workflow(
-        self,
-        task_scheduler: speedwagon.runner_strategies.TaskScheduler,
-        job: speedwagon.job.Workflow,
-        options: typing.Dict[str, Any],
-        logger: Optional[logging.Logger] = None
-    ) -> None:
-        """Run workflow."""
-        task_scheduler.logger = logger or logging.getLogger(__name__)
-        task_scheduler.request_more_info = self.request_more_info
-        task_scheduler.run(job, options)
-
-
 class WorkflowProgressCallbacks(runner_strategies.AbsJobCallbacks):
     """Callback class for communicating between Qt and Python."""
 
@@ -348,11 +148,6 @@ def __init__(
 
         self.signals = WorkflowProgressCallbacks.WorkflowSignals(dialog_box)
 
-        self.log_handler = \
-            qtwidgets.logging_helpers.SignalLogHandler(
-                signal=self.signals.message
-            )
-
     def log(self, text: str, level: int = logging.INFO) -> None:
         """Send a log message."""
         self.signals.log(text, level)
@@ -395,59 +190,3 @@ def update_progress(self, current: Optional[int],
     def status(self, text: str) -> None:
         """Set the status."""
         self.signals.set_status(text)
-
-
-class WorkRunnerExternal3(contextlib.AbstractContextManager):
-    """Work runner that uses external manager."""
-
-    def __init__(
-            self,
-            parent: typing.Optional[QtWidgets.QWidget] = None
-    ) -> None:
-        """Create a work runner."""
-        self.results: typing.List[speedwagon.tasks.Result] = []
-        self._parent = parent
-        self.abort_callback: Optional[Callable[[], None]] = None
-        self.was_aborted = False
-        self._dialog: Optional[
-            frontend.qtwidgets.dialog.WorkProgressBar
-        ] = None
-        # self.progress_dialog_box_handler: \
-        #     Optional[ProgressMessageBoxLogHandler] = None
-
-    @property
-    def dialog(self) -> Optional[frontend.qtwidgets.dialog.WorkProgressBar]:
-        """Get the progress bar dialog."""
-        warnings.warn("Don't use the dialog", DeprecationWarning)
-        return self._dialog
-
-    @dialog.setter
-    def dialog(
-            self,
-            value: Optional[frontend.qtwidgets.dialog.WorkProgressBar]
-    ) -> None:
-        self._dialog = value
-
-    def __enter__(self) -> WorkRunnerExternal3:
-        """Start worker."""
-        self.dialog = \
-            speedwagon.frontend.qtwidgets.dialog.WorkProgressBar(self._parent)
-
-        self.dialog.close()
-        return self
-
-    def abort(self) -> None:
-        """Abort on any running tasks."""
-        self.was_aborted = True
-        if callable(self.abort_callback):
-            self.abort_callback()  # pylint: disable=not-callable
-
-    def __exit__(self,
-                 exc_type: Optional[Type[BaseException]],
-                 exc_value: Optional[BaseException],
-                 exc_tb: Optional[TracebackType]) -> None:
-        """Close runner."""
-        if self.dialog is None:
-            raise AttributeError("dialog was set to None before closing")
-
-        self.dialog.close()
diff --git a/speedwagon/frontend/qtwidgets/tabs.py b/speedwagon/frontend/qtwidgets/tabs.py
index 650baa644..ca52b7d3c 100644
--- a/speedwagon/frontend/qtwidgets/tabs.py
+++ b/speedwagon/frontend/qtwidgets/tabs.py
@@ -1,617 +1,177 @@
 """Creating and managing tabs in the UI display."""
 from __future__ import annotations
 
-import abc
-import logging
-import os
-import sys
-import traceback
-import enum
 import typing
-from typing import List, Optional, Tuple, Dict, Iterator, NamedTuple, cast, \
-    Type, Any
-from abc import ABCMeta
+from typing import \
+    Optional, \
+    Type, \
+    Dict
 
-import yaml
-from PySide6 import QtWidgets, QtCore, QtGui  # type: ignore
-import speedwagon
-
-from speedwagon import runner_strategies
-from speedwagon.frontend import qtwidgets
+from PySide6 import QtWidgets, QtCore  # type: ignore
 
-from speedwagon.exceptions import MissingConfiguration, InvalidConfiguration
-from speedwagon.job import AbsWorkflow, NullWorkflow, Workflow
-from speedwagon.frontend.qtwidgets import models
+from speedwagon.frontend.qtwidgets.models import workflows as workflow_models
+from speedwagon.frontend.qtwidgets.models.common import WorkflowClassRole
 
+import speedwagon
+from speedwagon.config import StandardConfig, FullSettingsData
+from speedwagon.job import Workflow
+from speedwagon.frontend import qtwidgets
 if typing.TYPE_CHECKING:
-    from speedwagon.frontend.qtwidgets.worker import ToolJobManager
-    from speedwagon.workflow import AbsOutputOptionDataType
-
-
-__all__ = [
-    "ItemSelectionTab",
-    "WorkflowsTab",
-    "TabData",
-    "read_tabs_yaml",
-    "write_tabs_yaml",
-    "extract_tab_information"
-]
-
-SELECTOR_VIEW_SIZE_POLICY = QtWidgets.QSizePolicy(
-    QtWidgets.QSizePolicy.Policy.MinimumExpanding,
-    QtWidgets.QSizePolicy.Policy.Maximum)
+    from speedwagon.frontend.qtwidgets.widgets import \
+        Workspace, \
+        SelectWorkflow, \
+        UserDataType
 
-# There are correct
-WORKFLOW_SIZE_POLICY = QtWidgets.QSizePolicy(
-    QtWidgets.QSizePolicy.Policy.MinimumExpanding,
-    QtWidgets.QSizePolicy.Policy.Maximum)
 
-ITEM_SETTINGS_POLICY = QtWidgets.QSizePolicy(
-    QtWidgets.QSizePolicy.Policy.MinimumExpanding,
-    QtWidgets.QSizePolicy.Policy.Maximum)
+try:  # pragma: no cover
+    from importlib.resources import as_file
+    from importlib import resources
+except ImportError:  # pragma: no cover
+    import importlib_resources as resources  # type: ignore
+    from importlib_resources import as_file
 
 
-class TabsFileError(speedwagon.exceptions.SpeedwagonException):
-    """Error with Tabs File."""
+__all__ = ['WorkflowsTab3']
 
 
-class TabWidgets(enum.Enum):
-    NAME = "name"
-    DESCRIPTION = "description"
-    SETTINGS = "settings"
+class WorkflowsTab3UI(QtWidgets.QWidget):
+    start_button: QtWidgets.QPushButton
+    workspace: Workspace
+    workflow_selector: SelectWorkflow
 
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        with as_file(
+                resources.files(
+                    "speedwagon.frontend.qtwidgets.ui"
+                ).joinpath("create_job_tab.ui")
+        ) as ui_file:
+            qtwidgets.ui_loader.load_ui(str(ui_file), self)
 
-class Tab:
-    @abc.abstractmethod
-    def compose_tab_layout(self) -> None:
-        """Draw the layout of the tab."""
 
-    @abc.abstractmethod
-    def create_actions(self) -> Tuple[Dict[str, QtWidgets.QWidget],
-                                      QtWidgets.QLayout]:
-        """Generate action widgets."""
+class WorkflowsTab3(WorkflowsTab3UI):
+    """Workflows tab - version 3."""
 
-    def __init__(self,
-                 parent: QtWidgets.QWidget,
-                 work_manager: ToolJobManager
-                 ) -> None:
-        """Create a new tab."""
-        self.parent = parent
-        self.work_manager = work_manager
-        self.tab_widget, self.tab_layout = self.create_tab()
-        self.tab_widget.setSizePolicy(WORKFLOW_SIZE_POLICY)
-        self.tab_layout.setSpacing(20)
-
-    @staticmethod
-    def create_tab() -> Tuple[QtWidgets.QWidget, QtWidgets.QLayout]:
-        tab_tools = QtWidgets.QWidget()
-        tab_tools.setObjectName("tab")
-        tab_tools_layout = QtWidgets.QVBoxLayout(tab_tools)
-        tab_tools_layout.setObjectName("tab_layout")
-        return tab_tools, tab_tools_layout
-
-
-class ItemSelectionTab(Tab, metaclass=ABCMeta):
-    """Tab for selection of item."""
-
-    def __init__(
-            self,
-            name: str,
-            parent: QtWidgets.QWidget,
-            item_model: models.WorkflowListModel,
-            work_manager: ToolJobManager,
-            log_manager: logging.Logger
-    ) -> None:
-        """Create a new item selection tab."""
-        super().__init__(parent, work_manager)
-        self.export_action = QtGui.QAction(text="summy")
-        self.log_manager = log_manager
-        self.item_selection_model = item_model
-        self.options_model: Optional[models.ToolOptionsModel4] = None
-        self.tab_name = name
-
-        self.item_selector_view = self._create_selector_view(
-            parent,
-            model=self.item_selection_model
+    start_workflow = QtCore.Signal(str, dict)
+    workflow_selected = QtCore.Signal(object)
+    settings_changed = QtCore.Signal()
+
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        """Create a new WorkflowTab3 object."""
+        super().__init__(parent)
+        self._parent = parent
+        self.set_model(workflow_models.WorkflowList())
+        self.app_settings_lookup_strategy = StandardConfig()
+        self.start_button.clicked.connect(self.submit_job)
+        self.workflow_selector.selected_index_changed.connect(
+            self._handle_selector_changed
         )
-
-        self.workspace_group_box = QtWidgets.QGroupBox(parent)
-        self.workspace_group_box.setLayout(QtWidgets.QVBoxLayout())
-        self.workspace_group_box.setTitle(self.tab_name)
-
-        self.workspace_widget = \
-            qtwidgets.widgets.get_workspace(
-                self.item_selection_model,
-                parent=parent
+        self._workflow_selected: Optional[Type[speedwagon.Workflow]] = None
+        self.settings_changed.connect(self._update_okay_button)
+        self.settings_changed.emit()
+
+    def model(self) -> QtCore.QAbstractItemModel:
+        """Get the model used by the current tab."""
+        return self._model
+
+    def set_model(self, model: workflow_models.AbsWorkflowList) -> None:
+        """Set the current model used by the tab."""
+        self._model = model
+        self.workflow_selector.model = self._model
+
+    def _handle_selector_changed(self, index: QtCore.QModelIndex) -> None:
+        workflow = \
+            self._model.data(
+                index,
+                WorkflowClassRole
             )
-        self.workspace_group_box.layout().addWidget(self.workspace_widget)
-
-        self.actions_widgets, self.actions_layout = self.create_actions()
-        if self.item_selection_model.rowCount() == 0:
-            self.item_selector_view.setVisible(False)
-            self.workspace_group_box.setVisible(False)
-            # self.workspace.setVisible(False)
-            self._empty_tab_message = QtWidgets.QLabel()
-            self._empty_tab_message.setText("No items available to display")
-            self.tab_layout.addWidget(self._empty_tab_message)
-
-        self.init_selection()
-        # self._workspace_widget.layout().replaceWidget(
-        #     self._workspace_widget.settingsWidget,
-        #     self.settings_form
-        # )
-        self.tab_layout.addWidget(self.workspace_group_box)
-        self.compose_tab_layout()
 
-    def init_selection(self) -> None:
-        """Initialize selection.
+        self.workspace.set_workflow(workflow)
+        self._handle_workflow_changed(workflow)
 
-        Set the first item.
-        """
-        index = self.item_selection_model.index(0, 0)
-        self.item_selector_view.setCurrentIndex(index)
+    def set_current_workflow(self, workflow_name: str) -> None:
+        """Set current workflow by name."""
+        self.workflow_selector.set_current_by_name(workflow_name)
 
-    def _create_selector_view(
+    def set_current_workflow_settings(
             self,
-            parent: QtWidgets.QWidget,
-            model: QtCore.QAbstractTableModel
-    ) -> QtWidgets.QListView:
-
-        selector_view = QtWidgets.QListView(parent)
-        selector_view.setAlternatingRowColors(True)
-        selector_view.setUniformItemSizes(True)
-        selector_view.setModel(model)
-
-        min_rows_vis = 4
-
-        if model.rowCount() < min_rows_vis:
-            min_rows = model.rowCount()
-        else:
-            min_rows = min_rows_vis
-
-        selector_view.setFixedHeight(
-            (selector_view.sizeHintForRow(0) * min_rows) + 4
-        )
-
-        # selector_view.setSizePolicy(SELECTOR_VIEW_SIZE_POLICY)
-
-        selector_view.setSelectionBehavior(
-            QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
-        )
-        selection_model = selector_view.selectionModel()
-        selection_model.currentChanged.connect(  # type: ignore
-            self._update_tool_selected
-        )
-
-        return selector_view
-
-    @abc.abstractmethod
-    def start(self, item: typing.Type[Workflow]) -> None:
-        """Start item."""
-
-    @abc.abstractmethod
-    def get_item_options_model(
-            self,
-            workflow: Type[Workflow]
-    ) -> models.ToolOptionsModel4:
-        """Get item options model."""
-
-    def create_actions(self) -> Tuple[Dict[str, QtWidgets.QWidget],
-                                      QtWidgets.QLayout]:
-        """Create actions."""
-        tool_actions_layout = QtWidgets.QHBoxLayout()
-
-        start_button = QtWidgets.QPushButton()
-        start_button.setText("Start")
-        start_button.setContextMenuPolicy(
-            QtCore.Qt.ContextMenuPolicy.CustomContextMenu
-        )
-        # pylint: disable=no-member
-        start_button.clicked.connect(self._start)  # type: ignore
-
-        tool_actions_layout.addSpacerItem(
-            QtWidgets.QSpacerItem(
-                0,
-                40,
-                QtWidgets.QSizePolicy.Policy.Expanding
+            data: Dict[str, UserDataType]
+    ) -> None:
+        """Set settings for the current workflow."""
+        workflow_klass = self.workflow_selector.get_current_workflow_type()
+        if workflow_klass is None:
+            raise ValueError(
+                "Current Workflow not set. Workflow must be set first."
             )
+        workflow_inst = workflow_klass(
+            self.workspace.app_settings_lookup_strategy.settings()
         )
-
-        tool_actions_layout.addWidget(start_button)
-        actions = {
-            "start_button": cast(QtWidgets.QWidget, start_button)
-        }
-        return actions, tool_actions_layout
-
-    def _start(self) -> None:
-        self.workspace_widget.settings_form.update_model()
-        selected_workflow = cast(
-            typing.Type[Workflow],
-            self.item_selection_model.data(
-                self.item_selector_view.selectedIndexes()[0],
-                role=typing.cast(int, QtCore.Qt.ItemDataRole.UserRole)
-            )
+        qtwidgets.gui.load_job_settings_model(
+            data,
+            self.workspace.settings_form,
+            workflow_inst.job_options()
         )
-        try:
-            self.warn_user_of_invalid_settings(
-                workflow=selected_workflow,
-                checks=[
-                    models.check_required_settings_have_values,
-                ]
-            )
-        except InvalidConfiguration:
-            return
-        if self.is_ready_to_start():
-            try:
-                self.start(selected_workflow)
-            except MissingConfiguration as error_message:
-                config_error_dialog = QtWidgets.QMessageBox(self.parent)
-                config_error_dialog.setWindowTitle("Settings Error")
-                config_error_dialog.setDetailedText(f"{error_message}")
-                config_error_dialog.setText(
-                    "Speedwagon has a problem with current configuration "
-                    "settings"
-                )
-                config_error_dialog.exec_()
-
-    @abc.abstractmethod
-    def is_ready_to_start(self) -> bool:
-        """Check if the workflow is ready to start."""
 
-    def _update_tool_selected(
+    def _handle_workflow_changed(
             self,
-            current: QtCore.QModelIndex,
-            previous: QtCore.QModelIndex
+            workflow_klass: typing.Type[Workflow]
     ) -> None:
-        try:
-            if current.isValid():
-                self.item_selected(current)
-                self.workspace_widget.tool_mapper.setCurrentModelIndex(
-                    current
-                )
-        except Exception as error:
-            if previous.isValid():
-                self.item_selected(previous)
-                self.workspace_widget.tool_mapper.setCurrentModelIndex(
-                    previous
-                )
-
-            else:
-                traceback.print_tb(error.__traceback__)
-            self.item_selector_view.setCurrentIndex(previous)
-
-    def item_selected(self, index: QtCore.QModelIndex) -> None:
-        """Set the current selection based on the index."""
-        item = cast(
-            typing.Type[Workflow],
-            self.item_selection_model.data(
-                index, role=typing.cast(int, QtCore.Qt.ItemDataRole.UserRole)
-            )
-        )
-
-        #################
-        try:
-            model = self.get_item_options_model(item)
-            self.options_model = model
-
-            self.workspace_widget.settings_form.set_model(self.options_model)
-
-            # self.settings_form.setSizePolicy(ITEM_SETTINGS_POLICY)
-        except Exception as error:
-            traceback.print_exc()
-            stack_trace = traceback.format_exception(type(error),
-                                                     value=error,
-                                                     tb=error.__traceback__)
-            item_name = cast(AbsWorkflow, item).name
-            class_name = str(error.__class__.__name__)
-            message = f"Unable to use {item_name}. Reason: {class_name}"
-
-            warning_message_dialog = QtWidgets.QMessageBox(self.parent)
-            spanner = QtWidgets.QSpacerItem(
-                300,
-                0,
-                QtWidgets.QSizePolicy.Policy.Minimum,
-                QtWidgets.QSizePolicy.Policy.Expanding
-            )
-
-            warning_message_dialog.setWindowTitle("Settings Error")
-            warning_message_dialog.setIcon(QtWidgets.QMessageBox.Icon.Warning)
-            warning_message_dialog.setText(message)
-            warning_message_dialog.setDetailedText("".join(stack_trace))
-            layout = cast(
-                QtWidgets.QGridLayout,
-                warning_message_dialog.layout()
+        self._workflow_selected = workflow_klass
+        self.workspace.app_settings_lookup_strategy = \
+            self.app_settings_lookup_strategy
+        self.settings_changed.emit()
+        self.workflow_selected.emit(workflow_klass)
+
+    def get_app_config(self) -> FullSettingsData:
+        """Get app configuration."""
+        return self.app_settings_lookup_strategy.settings()
+
+    def _update_okay_button(self) -> None:
+        if self._workflow_selected is None:
+            self.start_button.setEnabled(False)
+        else:
+            self.start_button.setEnabled(True)
+
+    def submit_job(self) -> None:
+        """Submit new job."""
+        if not self.workspace.is_valid():
+            message = "\n".join(self.workspace.settings_form.issues)
+            config_error_dialog = QtWidgets.QMessageBox(self)
+            config_error_dialog.setWindowTitle("Settings Error")
+            config_error_dialog.setDetailedText(f"{message}")
+            config_error_dialog.setText(
+                "Speedwagon has a problem with current configuration "
+                "settings"
             )
-
-            layout.addItem(
-                spanner, layout.rowCount(), 0, 1, layout.columnCount())
-
-            warning_message_dialog.exec()
-
-            self.log_manager.warning(message)
-            raise
-
-    def compose_tab_layout(self) -> None:
-        """Build the tab widgets."""
-        self.tab_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
-        self.tab_layout.addWidget(self.item_selector_view)
-        self.tab_layout.addWidget(self.workspace_group_box)
-        # self.workspace_group_box.setMaximumHeight(300)
-        actions = QtWidgets.QWidget()
-        actions.setLayout(self.actions_layout)
-        self.tab_layout.addWidget(actions)
-
-    def warn_user_of_invalid_settings(
-            self,
-            checks: List[
-                typing.Callable[[AbsOutputOptionDataType], Optional[str]]
-            ],
-            workflow: Optional[Type[Workflow]] = None,
-    ) -> None:
-        """Generate dialog box reporting invalid settings discovered.
-
-        This is a noop if no errors found. However, if found any errors this
-        will raise InvalidConfiguration exception.
-        """
-        if not self.options_model:
+            config_error_dialog.exec_()
             return
-        errors = models.get_settings_errors(self.options_model, checks)
-        if workflow:
-            workflow_error = \
-                get_workflow_errors(self.options_model.get(), workflow)
-            if workflow_error:
-                errors.append(workflow_error)
-        if not errors:
-            return
-        error_message = '\n'.join(errors)
-        config_error_dialog = QtWidgets.QMessageBox(self.parent)
-        config_error_dialog.setWindowTitle("Settings Error")
-        config_error_dialog.setDetailedText(f"{error_message}")
-        config_error_dialog.setText(
-            "Speedwagon has a problem with current configuration "
-            "settings"
+        self.start_workflow.emit(
+            self.workspace.name,
+            self.workspace.configuration
         )
-        config_error_dialog.exec_()
-        raise InvalidConfiguration(errors)
-
-
-def get_workflow_errors(options, workflow):
-    try:
-        workflow.validate_user_options(**options)
-    except ValueError as error:
-        return str(error)
-    return None
-
-
-class WorkflowSignals(QtCore.QObject):
-    start_workflow = QtCore.Signal(str, dict)
-
-
-class WorkflowsTab(ItemSelectionTab):
-    """Workflow tab."""
-
-    def __init__(
-            self,
-            parent: QtWidgets.QWidget,
-            workflows: typing.Mapping[str, Type[speedwagon.job.Workflow]],
-            work_manager=None,
-            log_manager=None) -> None:
-        """Create a new workflow tab."""
-        super().__init__("Workflow", parent,
-                         qtwidgets.models.WorkflowListModel(workflows),
-                         work_manager,
-                         log_manager)
-        self.workflows = workflows
-
-    def is_ready_to_start(self) -> bool:
-        """Get if the workflow is ready to start.
-
-        Returns:
-            Returns True is ready, false if not ready.
-        """
-        number_of_selected_indexes = \
-            len(self.item_selector_view.selectedIndexes())
-
-        if number_of_selected_indexes != 1:
-            print(
-                "Invalid number of selected Indexes. "
-                f"Expected 1. Found {number_of_selected_indexes}"
-            )
-
-            return False
-        return True
-
-    def run(self, workflow: AbsWorkflow, options: Dict[str, Any]) -> None:
-        """Run a workflow with a given set of options."""
-        try:
-            workflow.validate_user_options(**options)
-
-            manager_strat = speedwagon.frontend.qtwidgets.runners.QtRunner(
-                parent=self.parent)
-            runner = runner_strategies.RunRunner(manager_strat)
-            runner.run(workflow, options, self.work_manager.logger)
 
-        except ValueError as exc:
-            msg = self._create_error_message_box_from_exception(exc)
-            msg.exec_()
+    @property
+    def workflows(self) -> Dict[str, Type[speedwagon.Workflow]]:
+        """Get all workflows."""
+        workflows = {}
+        for row_id in range(self._model.rowCount()):
+            index = self._model.index(row_id, 0)
+            workflow_name = self._model.data(index)
+            workflows[workflow_name] = \
+                self._model.data(
+                    index,
+                    role=WorkflowClassRole
+                )
+        return workflows
 
-        except speedwagon.exceptions.JobCancelled as job_cancel_exception:
-            msg = self._create_error_message_box_from_exception(
-                job_cancel_exception,
-                window_title="Job Cancelled"
-            )
-            if job_cancel_exception.expected is True:
-                msg.setIcon(QtWidgets.QMessageBox.Icon.Information)
-            else:
-                msg.setIcon(QtWidgets.QMessageBox.Icon.Warning)
-                traceback.print_tb(job_cancel_exception.__traceback__)
-                print(job_cancel_exception, file=sys.stderr)
-            msg.exec_()
+    def add_workflow(self, workflow: Type[speedwagon.Workflow]) -> None:
+        """Add a new workflow to the tab."""
+        if self._model is None:
             return
+        self._model.add_workflow(workflow)
 
-        except Exception as exc:
-            traceback.print_tb(exc.__traceback__)
-            print(exc, file=sys.stderr)
-            msg = self._create_error_message_box_from_exception(exc)
-            msg.setDetailedText(
-                "".join(traceback.format_exception(type(exc),
-                                                   exc,
-                                                   tb=exc.__traceback__))
-            )
-            msg.exec_()
-            return
-
-    def _create_error_message_box_from_exception(
-            self,
-            exc: BaseException,
-            window_title: Optional[str] = None,
-            message: Optional[str] = None
-    ) -> QtWidgets.QMessageBox:
-
-        message_box = QtWidgets.QMessageBox(self.parent)
-        message_box.setIcon(QtWidgets.QMessageBox.Icon.Warning)
-        window_title = window_title or exc.__class__.__name__
-        message_box.setWindowTitle(window_title)
-        message = message or str(exc)
-        message_box.setText(message)
-        return message_box
-
-    def start(self, item: typing.Type[Workflow]) -> None:
-        """Start a workflow."""
-        if self.work_manager.user_settings is None:
-            raise RuntimeError("user_settings is not set")
-        new_workflow = item(dict(self.work_manager.user_settings))
-
-        # Add global settings to workflow
-        assert isinstance(new_workflow, AbsWorkflow)
-
-        # new_workflow.global_settings.update(
-        #     dict(self.work_manager.user_settings))
-        if self.options_model is None:
-            raise RuntimeError("options_model not set")
-
-        user_options = self.options_model.get()
-
-        self.run(new_workflow, user_options)
-
-    def get_item_options_model(
-            self,
-            workflow: typing.Type[Workflow]
-    ) -> models.ToolOptionsModel4:
-        """Get item options model."""
-        user_options = self.get_item_user_options(workflow)
-        return \
-            qtwidgets.models.ToolOptionsModel4(user_options)
-
-    def get_item_user_options(self, workflow: typing.Type[Workflow]):
-        """Get user options from  workflow."""
-        if self.work_manager.user_settings is None:
-            raise ValueError("user_settings not set")
-        new_workflow = workflow(
-            global_settings=dict(self.work_manager.user_settings)
+    def current_workflow(self) -> str:
+        """Get the name of the current workflow."""
+        return self._model.data(
+            self.workflow_selector.workflowSelectionView.currentIndex()
         )
-        return new_workflow.get_user_options()
-
-
-class WorkflowsTab2(WorkflowsTab):
-    def __init__(self, parent: QtWidgets.QWidget,
-                 workflows: typing.Mapping[str, Type[speedwagon.job.Workflow]],
-                 # work_manager
-                 ) -> None:
-        super().__init__(parent, workflows)
-        self._workflows = workflows
-        self.signals = WorkflowSignals()
-
-    def get_item_options_model(self, workflow):
-        """Get item options model."""
-        new_workflow = workflow(global_settings=self.parent.user_settings)
-        return \
-            qtwidgets.models.ToolOptionsModel4(new_workflow.get_user_options())
-
-    def start(self, item: typing.Type[Workflow]) -> None:
-        if self.options_model is None:
-            raise RuntimeError("options_model not set")
-
-        self.signals.start_workflow.emit(item.name, self.options_model.get())
-
-
-class TabData(NamedTuple):
-    """Tab data."""
-
-    tab_name: str
-    workflows_model: models.WorkflowListModel2
-
-
-def read_tabs_yaml(yaml_file: str) -> Iterator[TabData]:
-    """Read a custom tab yaml file."""
-    tabs_file_size = os.path.getsize(yaml_file)
-    if tabs_file_size > 0:
-        try:
-            with open(yaml_file, encoding="utf-8") as file:
-                tabs_config_data = \
-                    yaml.load(file.read(), Loader=yaml.SafeLoader)
-            if not isinstance(tabs_config_data, dict):
-                raise TabsFileError("Failed to parse file")
-
-            for tab_name in tabs_config_data:
-                model = qtwidgets.models.WorkflowListModel2()
-                for workflow_name in tabs_config_data.get(tab_name, []):
-                    empty_workflow = \
-                        cast(
-                            Type[Workflow],
-                            type(
-                                workflow_name,
-                                (NullWorkflow,),
-                                {
-                                    "name": workflow_name
-                                }
-                            )
-                        )
-                    model.add_workflow(empty_workflow)
-                new_tab = TabData(tab_name, model)
-                yield new_tab
-
-        except FileNotFoundError as error:
-            print(
-                f"Custom tabs file not found. Reason: {error}",
-                file=sys.stderr
-            )
-            raise
-        except AttributeError as error:
-            print(
-                f"Custom tabs file failed to load. Reason: {error}",
-                file=sys.stderr
-            )
-            raise
-
-        except yaml.YAMLError as yaml_error:
-            print(
-                f"{yaml_file} file failed to load. "
-                f"Reason: {yaml_error}",
-                file=sys.stderr
-            )
-            raise
-
-
-def write_tabs_yaml(yaml_file: str, tabs: List[TabData]) -> None:
-    """Write out tab custom information to a yaml file."""
-    tabs_data = {}
-    for tab in tabs:
-        tab_model = tab.workflows_model
-
-        tabs_data[tab.tab_name] = \
-            [workflow.name for workflow in tab_model.workflows]
-
-    with open(yaml_file, "w", encoding="utf-8") as file_handle:
-        yaml.dump(tabs_data, file_handle, default_flow_style=False)
-
-
-def extract_tab_information(
-        model: qtwidgets.models.TabsModel
-) -> List[TabData]:
-    """Get tab information."""
-    tabs = []
-    for tab in model.tabs:
-        new_tab = TabData(tab.tab_name, tab.workflows_model)
-        tabs.append(new_tab)
-    return tabs
diff --git a/speedwagon/frontend/qtwidgets/ui/console.ui b/speedwagon/frontend/qtwidgets/ui/console.ui
index 8604dab9e..bf68bb65b 100644
--- a/speedwagon/frontend/qtwidgets/ui/console.ui
+++ b/speedwagon/frontend/qtwidgets/ui/console.ui
@@ -6,8 +6,8 @@
    <rect>
     <x>0</x>
     <y>0</y>
-    <width>400</width>
-    <height>300</height>
+    <width>280</width>
+    <height>216</height>
    </rect>
   </property>
   <property name="windowTitle">
diff --git a/speedwagon/frontend/qtwidgets/ui/create_job_tab.ui b/speedwagon/frontend/qtwidgets/ui/create_job_tab.ui
new file mode 100644
index 000000000..0fd083907
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/ui/create_job_tab.ui
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Form</class>
+ <widget class="QWidget" name="Form">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>554</width>
+    <height>411</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout_2">
+   <item>
+    <widget class="SelectWorkflow" name="workflow_selector"/>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="groupBox">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+       <horstretch>0</horstretch>
+       <verstretch>0</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="title">
+      <string>Workflow</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout">
+      <item>
+       <widget class="Workspace" name="workspace">
+        <property name="minimumSize">
+         <size>
+          <width>0</width>
+          <height>100</height>
+         </size>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QFrame" name="actionsBox">
+     <property name="frameShape">
+      <enum>QFrame::NoFrame</enum>
+     </property>
+     <property name="frameShadow">
+      <enum>QFrame::Raised</enum>
+     </property>
+     <layout class="QHBoxLayout" name="horizontalLayout">
+      <item>
+       <spacer name="horizontalSpacer">
+        <property name="orientation">
+         <enum>Qt::Horizontal</enum>
+        </property>
+        <property name="sizeHint" stdset="0">
+         <size>
+          <width>40</width>
+          <height>20</height>
+         </size>
+        </property>
+       </spacer>
+      </item>
+      <item>
+       <widget class="QPushButton" name="start_button">
+        <property name="text">
+         <string>Start</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>Workspace</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.widgets</header>
+  </customwidget>
+  <customwidget>
+   <class>SelectWorkflow</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.widgets</header>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+ <slots>
+  <signal>start_workflow1()</signal>
+ </slots>
+</ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/main_window2.ui b/speedwagon/frontend/qtwidgets/ui/main_window2.ui
index f3cbd2bf5..f610a491c 100644
--- a/speedwagon/frontend/qtwidgets/ui/main_window2.ui
+++ b/speedwagon/frontend/qtwidgets/ui/main_window2.ui
@@ -29,11 +29,33 @@
       <property name="childrenCollapsible">
        <bool>false</bool>
       </property>
+      <widget class="ItemTabsWidget" name="tab_widget">
+       <property name="text" stdset="0">
+        <string>Hello, speedwagon</string>
+       </property>
+      </widget>
+      <widget class="ToolConsole" name="console">
+       <property name="text" stdset="0">
+        <string>Hello, speedwagon</string>
+       </property>
+      </widget>
      </widget>
     </item>
    </layout>
   </widget>
  </widget>
+ <customwidgets>
+  <customwidget>
+   <class>ToolConsole</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.gui</header>
+  </customwidget>
+  <customwidget>
+   <class>ItemTabsWidget</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.gui</header>
+  </customwidget>
+ </customwidgets>
  <resources/>
  <connections/>
 </ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/main_window3.ui b/speedwagon/frontend/qtwidgets/ui/main_window3.ui
new file mode 100644
index 000000000..57c17ea19
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/ui/main_window3.ui
@@ -0,0 +1,159 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>546</width>
+    <height>607</height>
+   </rect>
+  </property>
+  <property name="minimumSize">
+   <size>
+    <width>300</width>
+    <height>500</height>
+   </size>
+  </property>
+  <property name="windowTitle">
+   <string>Speedwagon</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <layout class="QVBoxLayout" name="main_layout">
+    <item>
+     <widget class="QSplitter" name="main_splitter">
+      <property name="orientation">
+       <enum>Qt::Vertical</enum>
+      </property>
+      <property name="childrenCollapsible">
+       <bool>false</bool>
+      </property>
+      <widget class="ItemTabsWidget" name="tab_widget">
+      </widget>
+      <widget class="ToolConsole" name="console">
+      </widget>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menu_bar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>546</width>
+     <height>24</height>
+    </rect>
+   </property>
+   <widget class="QMenu" name="menuFile">
+    <property name="title">
+     <string>File</string>
+    </property>
+    <addaction name="action_export_logs"/>
+    <addaction name="separator"/>
+    <addaction name="action_exit"/>
+   </widget>
+   <widget class="QMenu" name="menuJob">
+    <property name="title">
+     <string>Job</string>
+    </property>
+    <addaction name="action_export_job"/>
+    <addaction name="action_import_job"/>
+   </widget>
+   <widget class="QMenu" name="menuSystem">
+    <property name="title">
+     <string>System</string>
+    </property>
+    <addaction name="action_system_info_requested"/>
+    <addaction name="action_open_application_preferences"/>
+   </widget>
+   <widget class="QMenu" name="menuHelp">
+    <property name="title">
+     <string>Help</string>
+    </property>
+    <addaction name="action_help_requested"/>
+    <addaction name="action_about"/>
+   </widget>
+   <addaction name="menuFile"/>
+   <addaction name="menuJob"/>
+   <addaction name="menuSystem"/>
+   <addaction name="menuHelp"/>
+  </widget>
+  <action name="action_export_logs">
+   <property name="icon">
+    <iconset>
+     <normaloff>.</normaloff>.</iconset>
+   </property>
+   <property name="text">
+    <string> &amp;Export Log</string>
+   </property>
+  </action>
+  <action name="action_exit">
+   <property name="text">
+    <string> &amp;Exit</string>
+   </property>
+  </action>
+  <action name="action_export_job">
+   <property name="text">
+    <string>Export</string>
+   </property>
+  </action>
+  <action name="action_import_job">
+   <property name="text">
+    <string>Import</string>
+   </property>
+  </action>
+  <action name="action_system_info_requested">
+   <property name="text">
+    <string>System Info</string>
+   </property>
+  </action>
+  <action name="action_help_requested">
+   <property name="text">
+    <string> &amp;Help</string>
+   </property>
+  </action>
+  <action name="action_about">
+   <property name="text">
+    <string> &amp;About</string>
+   </property>
+  </action>
+  <action name="action_open_application_preferences">
+   <property name="text">
+    <string>Settings</string>
+   </property>
+  </action>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>ToolConsole</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.gui</header>
+  </customwidget>
+  <customwidget>
+   <class>ItemTabsWidget</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.gui</header>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>action_exit</sender>
+   <signal>triggered()</signal>
+   <receiver>MainWindow</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>-1</x>
+     <y>-1</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>272</x>
+     <y>303</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/plugin_settings.ui b/speedwagon/frontend/qtwidgets/ui/plugin_settings.ui
new file mode 100644
index 000000000..b9e79c90f
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/ui/plugin_settings.ui
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Form</class>
+ <widget class="QWidget" name="Form">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>400</width>
+    <height>300</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <property name="leftMargin">
+    <number>0</number>
+   </property>
+   <property name="topMargin">
+    <number>0</number>
+   </property>
+   <property name="rightMargin">
+    <number>0</number>
+   </property>
+   <property name="bottomMargin">
+    <number>0</number>
+   </property>
+   <item>
+    <widget class="QGroupBox" name="groupBox">
+     <property name="title">
+      <string>Enable Plugins</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_2">
+      <property name="leftMargin">
+       <number>0</number>
+      </property>
+      <property name="topMargin">
+       <number>0</number>
+      </property>
+      <property name="rightMargin">
+       <number>0</number>
+      </property>
+      <property name="bottomMargin">
+       <number>0</number>
+      </property>
+      <item>
+       <widget class="QListView" name="plugin_list_view"/>
+      </item>
+     </layout>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/select_workflow_widget.ui b/speedwagon/frontend/qtwidgets/ui/select_workflow_widget.ui
new file mode 100644
index 000000000..bde2efa47
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/ui/select_workflow_widget.ui
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Form</class>
+ <widget class="QWidget" name="Form">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>500</width>
+    <height>161</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <property name="leftMargin">
+    <number>0</number>
+   </property>
+   <property name="topMargin">
+    <number>0</number>
+   </property>
+   <property name="rightMargin">
+    <number>0</number>
+   </property>
+   <property name="bottomMargin">
+    <number>0</number>
+   </property>
+   <item>
+    <widget class="QListView" name="workflowSelectionView">
+     <property name="alternatingRowColors">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/setup_job.ui b/speedwagon/frontend/qtwidgets/ui/setup_job.ui
index 7f1fb5a14..38d2b1d4e 100644
--- a/speedwagon/frontend/qtwidgets/ui/setup_job.ui
+++ b/speedwagon/frontend/qtwidgets/ui/setup_job.ui
@@ -20,6 +20,13 @@
    </item>
   </layout>
  </widget>
+ <customwidgets>
+  <customwidget>
+   <class>Workspace</class>
+   <extends>QWidget</extends>
+   <header>speedwagon.frontend.qtwidgets.widgets</header>
+  </customwidget>
+ </customwidgets>
  <resources/>
  <connections/>
 </ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/tab_workflow_options.ui b/speedwagon/frontend/qtwidgets/ui/tab_workflow_options.ui
new file mode 100644
index 000000000..5637995b1
--- /dev/null
+++ b/speedwagon/frontend/qtwidgets/ui/tab_workflow_options.ui
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Form</class>
+ <widget class="QWidget" name="Form">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>400</width>
+    <height>318</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <property name="leftMargin">
+    <number>0</number>
+   </property>
+   <property name="topMargin">
+    <number>0</number>
+   </property>
+   <property name="rightMargin">
+    <number>0</number>
+   </property>
+   <property name="bottomMargin">
+    <number>0</number>
+   </property>
+   <item>
+    <widget class="QTreeView" name="workflow_settings_view">
+     <property name="editTriggers">
+      <set>QAbstractItemView::AllEditTriggers</set>
+     </property>
+     <property name="alternatingRowColors">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/speedwagon/frontend/qtwidgets/ui/workspace.ui b/speedwagon/frontend/qtwidgets/ui/workspace.ui
index f76b1f0f4..73093b5b3 100644
--- a/speedwagon/frontend/qtwidgets/ui/workspace.ui
+++ b/speedwagon/frontend/qtwidgets/ui/workspace.ui
@@ -1,13 +1,13 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <ui version="4.0">
  <class>workspace</class>
- <widget class="Workspace" name="workspace">
+ <widget class="Form" name="workspace">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>361</width>
-    <height>283</height>
+    <height>209</height>
    </rect>
   </property>
   <property name="windowTitle">
@@ -25,7 +25,7 @@
     </widget>
    </item>
    <item row="1" column="1">
-    <widget class="QLineEdit" name="selectedWorkflowView">
+    <widget class="QLineEdit" name="workflow_name_value">
      <property name="text">
       <string/>
      </property>
@@ -42,17 +42,23 @@
     </widget>
    </item>
    <item row="2" column="1">
-    <widget class="QTextBrowser" name="descriptionView">
+    <widget class="QTextBrowser" name="workflow_description_value">
      <property name="sizePolicy">
-      <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
        <horstretch>0</horstretch>
        <verstretch>0</verstretch>
       </sizepolicy>
      </property>
+     <property name="minimumSize">
+      <size>
+       <width>0</width>
+       <height>50</height>
+      </size>
+     </property>
      <property name="maximumSize">
       <size>
        <width>16777215</width>
-       <height>100</height>
+       <height>75</height>
       </size>
      </property>
     </widget>
@@ -65,17 +71,29 @@
     </widget>
    </item>
    <item row="3" column="1">
-    <widget class="QWidget" name="settingsWidget" native="true"/>
+    <widget class="DynamicForm" name="settings_form" native="true">
+     <property name="minimumSize">
+      <size>
+       <width>0</width>
+       <height>100</height>
+      </size>
+     </property>
+    </widget>
    </item>
   </layout>
  </widget>
  <customwidgets>
   <customwidget>
-   <class>Workspace</class>
+   <class>Form</class>
    <extends>QWidget</extends>
-   <header>workspace.h</header>
+   <header>form.h</header>
    <container>1</container>
   </customwidget>
+  <customwidget>
+   <class>DynamicForm</class>
+   <extends>QWidget</extends>
+   <header>dynamicform.h</header>
+  </customwidget>
  </customwidgets>
  <resources/>
  <connections/>
diff --git a/speedwagon/frontend/qtwidgets/ui_loader.py b/speedwagon/frontend/qtwidgets/ui_loader.py
index b3f5d1723..a4a670f3b 100644
--- a/speedwagon/frontend/qtwidgets/ui_loader.py
+++ b/speedwagon/frontend/qtwidgets/ui_loader.py
@@ -1,9 +1,12 @@
 import typing
 
 from PySide6.QtUiTools import QUiLoader
-from PySide6.QtCore import QMetaObject
 from PySide6 import QtWidgets
-
+from speedwagon.frontend.qtwidgets.gui import ToolConsole, ItemTabsWidget
+from speedwagon.frontend.qtwidgets.widgets import \
+    DynamicForm, \
+    Workspace, \
+    SelectWorkflow
 __all__ = ['load_ui']
 
 
@@ -35,6 +38,9 @@ def load_ui(
         base_instance: typing.Optional[QtWidgets.QWidget] = None
 ) -> QtWidgets.QWidget:
     loader = UiLoader(base_instance)
-    widget = loader.load(ui_file)
-    QMetaObject.connectSlotsByName(widget)
-    return widget
+    loader.registerCustomWidget(ToolConsole)
+    loader.registerCustomWidget(ItemTabsWidget)
+    loader.registerCustomWidget(DynamicForm)
+    loader.registerCustomWidget(SelectWorkflow)
+    loader.registerCustomWidget(Workspace)
+    return loader.load(ui_file)
diff --git a/speedwagon/frontend/qtwidgets/user_interaction.py b/speedwagon/frontend/qtwidgets/user_interaction.py
index f991c0d6b..bd3e5ec23 100644
--- a/speedwagon/frontend/qtwidgets/user_interaction.py
+++ b/speedwagon/frontend/qtwidgets/user_interaction.py
@@ -4,6 +4,11 @@
 import threading
 import typing
 from typing import Dict, Any, Optional, List, Union, Type
+try:  # pragma: no cover
+    from typing import TypedDict
+except ImportError:  # pragma: no cover
+    from typing_extensions import TypedDict
+
 from PySide6 import QtWidgets, QtCore
 from PySide6.QtGui import Qt
 from uiucprescon.packager import Metadata
@@ -18,6 +23,13 @@
     from speedwagon.job import Workflow
 
 
+class ConfirmItem(TypedDict):
+    """Confirm plugin by name."""
+
+    name: str
+    checked: Qt.CheckState
+
+
 class QtWidgetFactory(interaction.UserRequestFactory):
     """Factory for generating Qt Widget."""
 
@@ -53,23 +65,31 @@ def __init__(
             items: Optional[List[str]] = None,
             parent: Optional[QtCore.QObject] = None
     ) -> None:
-        """Create a new confirm list model."""
+        """Create a new model."""
         super().__init__(parent)
-        self.items = items or []
+        self._items: List[ConfirmItem] = []
+        if items:
+            self._set_items(items)
+
+    def _set_items(self, item_names: List[str]) -> None:
+        self._items = [{
+            "name": i,
+            "checked": Qt.CheckState.Checked
+        } for i in item_names
+        ]
+        self.itemsChanged.emit()
 
     @property
-    def items(self):
+    def items(self) -> List[str]:
         """Get the items."""
-        return self._items
+        keys = []
+        for item in self._items:
+            keys.append(item['name'])
+        return keys
 
     @items.setter
-    def items(self, value):
-        self._items = [{
-                "name": i,
-                "checked": Qt.CheckState.Checked
-            } for i in value
-        ]
-        self.itemsChanged.emit()
+    def items(self, value: List[str]) -> None:
+        self._set_items(value)
 
     def selected(self) -> List[str]:
         """Get items in the model that have been checked."""
@@ -80,7 +100,12 @@ def selected(self) -> List[str]:
                 self.data(index, Qt.ItemDataRole.CheckStateRole)
 
             if checked == Qt.CheckState.Checked:
-                selected.append(self.data(index, Qt.ItemDataRole.DisplayRole))
+                selected.append(
+                    typing.cast(
+                        str,
+                        self.data(index, Qt.ItemDataRole.DisplayRole)
+                    )
+                )
         return selected
 
     def rowCount(  # pylint: disable=invalid-name
@@ -202,7 +227,7 @@ def update_buttons(self) -> None:
         else:
             ok_button.setEnabled(False)
 
-    def _make_connections(self):
+    def _make_connections(self) -> None:
         # pylint: disable=E1101
         self.button_box.accepted.connect(self.accept)
         self.button_box.rejected.connect(self.reject)
diff --git a/speedwagon/frontend/qtwidgets/widgets.py b/speedwagon/frontend/qtwidgets/widgets.py
index 5b145bae0..e33eb3b4e 100644
--- a/speedwagon/frontend/qtwidgets/widgets.py
+++ b/speedwagon/frontend/qtwidgets/widgets.py
@@ -1,30 +1,36 @@
 """Specialize widgets."""
+from __future__ import annotations
 import abc
 import json
 import os.path
 import typing
+from collections import defaultdict
 from typing import \
     Union, \
     Optional, \
     Dict, \
     Any, \
     TypedDict, \
-    List
+    List, \
+    Iterable
 
-try:
-    from typing import TypeAlias
-except ImportError:
-    from typing_extensions import TypeAlias
 
-try:
+try:  # pragma: no cover
     from typing import NotRequired
-except ImportError:
+except ImportError:  # pragma: no cover
     from typing_extensions import NotRequired
 
 
 from PySide6 import QtWidgets, QtCore, QtGui
-from speedwagon.frontend.qtwidgets import models, ui_loader, ui
-from speedwagon.workflow import AbsOutputOptionDataType
+from speedwagon.frontend.qtwidgets import ui_loader, ui
+from speedwagon.workflow import AbsOutputOptionDataType, UserDataType
+from speedwagon import Workflow, exceptions, config
+
+from speedwagon.frontend.qtwidgets.models.workflows import AbsWorkflowList
+
+from speedwagon.frontend.qtwidgets import models
+
+
 try:  # pragma: no cover
     from importlib.resources import as_file
     from importlib import resources
@@ -33,12 +39,7 @@
     from importlib_resources import as_file
 
 
-__all__ = [
-    'get_workspace'
-]
-
-
-UseDataType: TypeAlias = Union[str, bool, None]
+__all__ = ['Workspace']
 
 
 class WidgetMetadata(TypedDict):
@@ -47,7 +48,7 @@ class WidgetMetadata(TypedDict):
     filter: NotRequired[str]
     selections: NotRequired[List[str]]
     placeholder_text: NotRequired[str]
-    value: NotRequired[UseDataType]
+    value: NotRequired[UserDataType]
 
 
 class EditDelegateWidget(QtWidgets.QWidget):
@@ -57,6 +58,7 @@ class EditDelegateWidget(QtWidgets.QWidget):
         When https://bugreports.qt.io/browse/PYSIDE-1434 is resolved, this
         should be made into an abstract base class.
     """
+
     editingFinished = QtCore.Signal()
     dataChanged = QtCore.Signal()
 
@@ -71,15 +73,15 @@ def __init__(
         inner_layout = QtWidgets.QHBoxLayout()
         inner_layout.setContentsMargins(0, 0, 0, 0)
         self.setLayout(inner_layout)
-        self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
+        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
 
     @property
-    def data(self) -> UseDataType:
+    def data(self) -> UserDataType:
         return self._data
 
     @data.setter
     @abc.abstractmethod
-    def data(self, value: UseDataType) -> None:
+    def data(self, value: UserDataType) -> None:
         """Set the value based on the widget used."""
 
 
@@ -213,7 +215,7 @@ def get_selections(self) -> List[str]:
         model = self.combo_box.model()
         selections = []
         for i in range(model.rowCount()):
-            index = model.index(i)
+            index = model.index(i, column=0)
             selections.append(model.data(index))
         return selections
 
@@ -289,7 +291,7 @@ def extract_path_from_event(
 
     @abc.abstractmethod
     def drop_acceptable_data(self, param) -> bool:
-        """check if dropped item is accessible"""
+        """Check if dropped item is accessible."""
 
 
 class DirectorySelectWidget(FileSystemItemSelectWidget):
@@ -372,9 +374,7 @@ def use_qt_file_dialog() -> Optional[str]:
                     parent=self,
                     filter=self.filter
                 )
-            if result:
-                return result[0]
-            return None
+            return result[0] if result else None
 
         selection = (get_file_callback or use_qt_file_dialog)()
         if selection:
@@ -432,7 +432,9 @@ def update_widget(self) -> None:
             serialized_json_data: str = \
                 typing.cast(
                     str,
-                    index.data(role=models.ToolOptionsModel4.JsonDataRole)
+                    index.data(
+                        role=models.ToolOptionsModel4.JsonDataRole
+                    )
                 )
             json_data = \
                 typing.cast(WidgetMetadata, json.loads(serialized_json_data))
@@ -461,16 +463,16 @@ def update_model(self) -> None:
                 return
             model_data = typing.cast(
                 AbsOutputOptionDataType,
-                self.model.data(
-                    index,
-                    models.ToolOptionsModel4.DataRole
-                )
+                self.model.data(index, models.ToolOptionsModel4.DataRole)
             )
 
             self.model.setData(index, self.widgets[model_data.label].data)
 
     @staticmethod
-    def iter_row_rect(layout: QtWidgets.QFormLayout, device):
+    def iter_row_rect(
+            layout: QtWidgets.QFormLayout,
+            device: InnerForm
+    ) -> Iterable[QtCore.QRect]:
         last_height = 0
         for row in range(layout.rowCount()):
             label = \
@@ -495,7 +497,7 @@ def paintEvent(self, event: QtGui.QPaintEvent) -> None:
         for i, rect in enumerate(
                 self.iter_row_rect(
                     typing.cast(QtWidgets.QFormLayout, self.layout()),
-                    painter.device()
+                    typing.cast(InnerForm, painter.device())
                 )
         ):
             if i % 2 == 0:
@@ -546,6 +548,29 @@ def __init__(
         self.modelChanged.connect(self._background.modelChanged)
         self.setWidget(self._background)
         self.ensurePolished()
+        self.issues: List[str] = []
+
+    def update_issues(self) -> None:
+        self.issues.clear()
+        self.update_model()
+        rows = self._background.model.rowCount()
+        for row in range(rows):
+            index = self._background.model.index(row)
+            data = typing.cast(
+                AbsOutputOptionDataType,
+                self._background.model.data(
+                    index,
+                    self._background.model.DataRole
+                )
+            )
+            if data.required and (data.value is None or data.value == ""):
+                self.issues.append(
+                    f" Required value {data.label} is missing."
+                )
+
+    def is_valid(self) -> bool:
+        self.update_issues()
+        return len(self.issues) == 0
 
     def create_editor(
             self,
@@ -562,54 +587,259 @@ def set_model(self, model: models.ToolOptionsModel4) -> None:
     def update_model(self) -> None:
         self._background.update_model()
 
-    def update_widget(self):
+    def update_widget(self) -> None:
         self._background.update_widget()
 
     @property
-    def model(self):
+    def model(self) -> models.ToolOptionsModel4:
         return self._background.model
 
+    def get_configuration(self) -> Dict[str, UserDataType]:
+        self.update_model()
+        return self._background.model.get()
+
 
 class Workspace(QtWidgets.QWidget):
+    """Workspace widget.
+
+    This widget contains the controls for a user to set up a new job.
+    """
+
     settingsWidget: QtWidgets.QWidget
-    selectedWorkflowView: QtWidgets.QLineEdit
+    workflow_name_value: QtWidgets.QLineEdit
     descriptionView: QtWidgets.QTextBrowser
+    selectedWorkflowNameLabel: QtWidgets.QLabel
+    workflow_description_value: QtWidgets.QTextBrowser
     settings_form: DynamicForm
 
-    def __init__(
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        """Create Workspace widget."""
+        super().__init__(parent)
+        with as_file(
+                resources.files(ui).joinpath("workspace.ui")
+        ) as ui_file:
+            ui_loader.load_ui(str(ui_file), self)
+        self.app_settings_lookup_strategy = config.StandardConfig()
+
+    def set_workflow(self, workflow_klass: typing.Type[Workflow]) -> None:
+        """Set current workflow."""
+        if workflow_klass.name:
+            self.workflow_name_value.setText(workflow_klass.name)
+        try:
+            settings = self.app_settings_lookup_strategy.settings()
+            new_workflow = \
+                workflow_klass(global_settings=settings.get("GLOBAL", {}))
+            config_backend = config.get_config_backend()
+            config_backend.workflow = new_workflow
+            new_workflow.set_options_backend(config_backend)
+            if new_workflow.description:
+                self.workflow_description_value.setText(
+                    new_workflow.description
+                )
+            self.settings_form.set_model(
+                models.ToolOptionsModel4(
+                    new_workflow.job_options()
+                )
+            )
+        except exceptions.MissingConfiguration as exc:
+            self.workflow_description_value.setHtml(
+                f"<b>Workflow unavailable.</b><p><b>Reason: </b>{exc}</p>"
+            )
+            self.settings_form.set_model(
+                models.ToolOptionsModel4())
+
+    def is_valid(self) -> bool:
+        """Check if the workflow configured is valid."""
+        return self.settings_form.is_valid()
+
+    def _get_configuration(self) -> Dict[str, UserDataType]:
+        return self.settings_form.get_configuration()
+
+    @property
+    def workflow_name(self) -> str:
+        """Get workflow name."""
+        return self._get_workflow_name()
+
+    def _get_workflow_name(self) -> str:
+        return self.workflow_name_value.text()
+
+    @property
+    def workflow_description(self) -> str:
+        """Get workflow description."""
+        return self._get_workflow_description()
+
+    def _get_workflow_description(self) -> str:
+        return self.workflow_description_value.toPlainText()
+
+    name = QtCore.Property(str, _get_workflow_name)
+    description = QtCore.Property(str, _get_workflow_description)
+    configuration = QtCore.Property(object, _get_configuration)
+
+
+class SelectWorkflow(QtWidgets.QWidget):
+    workflowSelectionView: QtWidgets.QListView
+    workflow_selected = QtCore.Signal(object)
+    selected_index_changed = QtCore.Signal(QtCore.QModelIndex)
+
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        with as_file(
+                resources.files(
+                    "speedwagon.frontend.qtwidgets.ui"
+                ).joinpath("select_workflow_widget.ui")
+        ) as ui_file:
+            ui_loader.load_ui(str(ui_file), self)
+        self.workflowSelectionView.setModel(models.WorkflowList())
+
+    @property
+    def model(self) -> QtCore.QAbstractItemModel:
+        return self.workflowSelectionView.model()
+
+    @model.setter
+    def model(self, value: AbsWorkflowList) -> None:
+        # pass
+        self.workflowSelectionView.setModel(value)
+        selection_model = self.workflowSelectionView.selectionModel()
+        selection_model.currentChanged.connect(  # type: ignore
+            self._update_tool_selected
+        )
+
+    def _update_tool_selected(
             self,
-            model: models.WorkflowListModel,
-            parent: Optional[QtWidgets.QWidget] = None
+            current: QtCore.QModelIndex,
+            previous: QtCore.QModelIndex
+    ) -> None:
+        item = typing.cast(
+            typing.Type[Workflow],
+            self.model.data(
+                current,
+                role=typing.cast(int, models.WorkflowClassRole)
+            )
+        )
+        self.selected_index_changed.emit(current)
+        self.workflow_selected.emit(item)
+
+    def add_workflow(self, workflow_klass: typing.Type[Workflow]) -> None:
+        new_row = self.model.rowCount()
+        self.model.insertRow(new_row)
+        self.model.setData(self.model.index(new_row, 0), workflow_klass)
+
+    def set_current_by_name(self, workflow_name: str) -> None:
+        rows = self.model.rowCount()
+        for row_id in range(rows):
+            workflow_index = self.model.index(row_id, 0)
+            name = workflow_index.data()
+            if name == workflow_name:
+                self.workflowSelectionView.setCurrentIndex(workflow_index)
+                self.workflow_selected.emit(
+                    self.model.data(workflow_index, models.WorkflowClassRole)
+                )
+                break
+        else:
+            raise ValueError(f"{workflow_name} not loaded in model")
+
+    def get_current_workflow_type(self) -> Optional[typing.Type[Workflow]]:
+        return typing.cast(
+            typing.Type[Workflow],
+            self.model.data(
+                self.workflowSelectionView.currentIndex(),
+                role=typing.cast(int, models.WorkflowClassRole)
+            )
+        )
+
+    @property
+    def workflows(self) -> Dict[str, typing.Type[Workflow]]:
+        loaded_workflows = {}
+        for row_id in range(self.model.rowCount()):
+            index = self.model.index(row_id, 0)
+            workflow_name = typing.cast(
+                str,
+                self.model.data(index, QtCore.Qt.ItemDataRole.DisplayRole)
+            )
+
+            loaded_workflows[workflow_name] = typing.cast(
+                typing.Type[Workflow],
+                self.model.data(index, QtCore.Qt.ItemDataRole.UserRole)
+            )
+
+        return loaded_workflows
+
+
+class PluginConfig(QtWidgets.QWidget):
+    changes_made = QtCore.Signal()
+    plugin_list_view: QtWidgets.QListView
+
+    def __init__(
+        self,
+        parent: Optional[QtWidgets.QWidget] = None,
     ) -> None:
         super().__init__(parent)
-        self.tool_mapper = QtWidgets.QDataWidgetMapper(self)
-        self.model = model
-        self.tool_mapper.setModel(self.model)
-
-
-def get_workspace(
-        workflow_model: models.WorkflowListModel,
-        parent: Optional[QtWidgets.QWidget] = None
-) -> Workspace:
-    """Get Workspace widget."""
-    with as_file(
-            resources.files(ui).joinpath("workspace.ui")
-    ) as ui_file:
-        widget = \
-            typing.cast(
-                Workspace,
-                ui_loader.load_ui(
-                    str(ui_file),
-                    Workspace(workflow_model, parent)
-                ),
+        with as_file(
+                resources.files(ui).joinpath("plugin_settings.ui")
+        ) as ui_file:
+            ui_loader.load_ui(str(ui_file), self)
+
+        self.model = models.PluginActivationModel()
+        self.model.dataChanged.connect(self.changes_made)
+        self.plugin_list_view.setModel(self.model)
+
+    @property
+    def modified(self) -> bool:
+        return self.model.data_modified
+
+    def enabled_plugins(self) -> Dict[str, List[str]]:
+
+        active_plugins: Dict[str, List[str]] = defaultdict(list)
+        for i in range(self.model.rowCount()):
+            checked = typing.cast(
+                QtCore.Qt.ItemDataRole,
+                self.model.data(
+                    self.model.index(i), QtCore.Qt.ItemDataRole.CheckStateRole
+                )
             )
 
-    widget.tool_mapper.addMapping(widget.selectedWorkflowView, 0)
-    widget.tool_mapper.addMapping(widget.descriptionView, 1, b"plainText")
-    widget.settings_form = DynamicForm(widget)
-    widget.layout().replaceWidget(
-        widget.settingsWidget,
-        widget.settings_form
-    )
-    widget.settings_form.setMinimumHeight(100)
-    return widget
+            if checked == QtCore.Qt.CheckState.Checked:
+                source = typing.cast(
+                    str,
+                    self.model.data(self.model.index(i), self.model.ModuleRole)
+                )
+                plugin_name = \
+                    typing.cast(
+                        str,
+                        self.model.data(
+                            self.model.index(i),
+                            QtCore.Qt.ItemDataRole.DisplayRole
+                        )
+                    )
+                active_plugins[source].append(plugin_name)
+        return dict(active_plugins)
+
+
+class WorkflowSettingsEditorUI(QtWidgets.QWidget):
+    workflow_settings_view: QtWidgets.QTreeView
+
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        with as_file(
+                resources.files(ui).joinpath("tab_workflow_options.ui")
+        ) as ui_file:
+            ui_loader.load_ui(str(ui_file), self)
+
+
+class WorkflowSettingsEditor(WorkflowSettingsEditorUI):
+    _model: QtCore.QAbstractItemModel
+    data_changed = QtCore.Signal()
+
+    def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
+        super().__init__(parent)
+        self.model = models.WorkflowSettingsModel()
+
+    @property
+    def model(self) -> QtCore.QAbstractItemModel:
+        return self._model
+
+    @model.setter
+    def model(self, value: QtCore.QAbstractItemModel) -> None:
+        self._model = value
+        self.workflow_settings_view.setModel(self._model)
+        self.model.dataChanged.connect(self.data_changed)
diff --git a/speedwagon/job.py b/speedwagon/job.py
index 48032ce46..7c49b4639 100644
--- a/speedwagon/job.py
+++ b/speedwagon/job.py
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 import abc
-import importlib
+import importlib.util
 import inspect
 import json
 import logging
@@ -19,10 +19,19 @@
     Tuple, \
     Type
 
+import speedwagon
+import speedwagon.config
+if sys.version_info < (3, 10):  # pragma: no cover
+    import importlib_metadata as metadata
+else:  # pragma: no cover
+    from importlib import metadata
+
 if typing.TYPE_CHECKING:
     from speedwagon.workflow import AbsOutputOptionDataType
     from speedwagon.frontend.interaction import UserRequestFactory
     from speedwagon.tasks import TaskBuilder, Result
+    from speedwagon.config import SettingsData, SettingsDataType
+
 
 __all__ = [
     "AbsWorkflow",
@@ -40,7 +49,7 @@ class AbsWorkflow(metaclass=abc.ABCMeta):
     active = True
     description: Optional[str] = None
     name: Optional[str] = None
-    global_settings: Dict[str, str] = {}
+    global_settings: SettingsData = {}
     required_settings_keys: Set[str] = set()
 
     # pylint: disable=unused-argument
@@ -161,6 +170,15 @@ class Workflow(AbsWorkflow):  # pylint: disable=abstract-method
         You need to implement the discover_task_metadata() method.
     """
 
+    def __init__(self, *args, **kwargs) -> None:
+        """Create a new workflow object."""
+        super().__init__(*args, **kwargs)
+        self._options_backends = None
+
+    def set_options_backend(self, value) -> None:
+        """Set the option backend."""
+        self._options_backends = value
+
     def get_additional_info(
             self,
             user_request_factory:  # pylint: disable=unused-argument
@@ -183,13 +201,29 @@ def get_additional_info(
         """
         return {}
 
-    def get_user_options(self) -> List[AbsOutputOptionDataType]:
+    def job_options(self) -> List[AbsOutputOptionDataType]:
         """Get user options.
 
         Defaults to no args.
         """
         return []
 
+    def workflow_options(self) -> List[AbsOutputOptionDataType]:
+        """Get options configured at the application level.
+
+        Defaults to no args.
+        """
+        return []
+
+    def get_workflow_configuration_value(
+            self,
+            key: str
+    ) -> Optional[SettingsDataType]:
+        """Get a value from the workflow configuration."""
+        if self._options_backends is None:
+            return None
+        return self._options_backends.get(key)
+
 
 class NullWorkflow(Workflow):
     """Null Workflow.
@@ -197,6 +231,8 @@ class NullWorkflow(Workflow):
     Does nothing.
     """
 
+    name = ''
+    description = ''
     def discover_task_metadata(self, initial_results: List[Any],
                                additional_data, **user_args) -> List[dict]:
         """Discover task metadata."""
@@ -216,7 +252,7 @@ def __init__(self, path: str) -> None:
     def py_module_filter(item: "os.DirEntry[str]") -> bool:
         pass
 
-    def locate(self) -> Dict["str", AbsWorkflow]:
+    def locate(self) -> Dict["str", Type[Workflow]]:
         """Locate workflows."""
         located_class = {}
         tree = os.scandir(self.path)
@@ -272,12 +308,34 @@ def package_name(self) -> str:
         return f"{__package__}.workflows"
 
     @property
-    def base_class(self) -> typing.Type[AbsWorkflow]:
+    def base_class(self) -> Type[AbsWorkflow]:
         """Get base class."""
         return AbsWorkflow
 
 
-def available_workflows() -> dict:
+class AbsWorkflowFinder(abc.ABC):  # pylint: disable=too-few-public-methods
+    @abc.abstractmethod
+    def locate(self) -> Dict[str, Type[Workflow]]:
+        """Locate workflows from everywhere."""
+
+
+class FindAllWorkflowsStrategy(AbsWorkflowFinder):
+
+    @staticmethod
+    def get_plugin_strategy() -> speedwagon.job.LoadWhiteListedPluginsOnly:
+        plugin_strategy = speedwagon.job.LoadWhiteListedPluginsOnly()
+        plugin_strategy.whitelisted_entry_points = \
+            speedwagon.config.get_whitelisted_plugins()
+        return plugin_strategy
+
+    def locate(self) -> Dict[str, Type[Workflow]]:
+        return {
+            **speedwagon.job.find_builtin_workflows(),
+            **speedwagon.job.find_plugin_workflows(self.get_plugin_strategy()),
+        }
+
+
+def available_workflows(strategy: Optional[AbsWorkflowFinder] = None) -> dict:
     """Locate all workflow class in workflows subpackage.
 
     This looks for a workflow prefix in the naming.
@@ -286,13 +344,12 @@ def available_workflows() -> dict:
         Dictionary of all workflow
 
     """
-    root = os.path.join(os.path.dirname(__file__), "workflows")
-    finder = WorkflowFinder(root)
-    return finder.locate()
+    strategy = strategy or FindAllWorkflowsStrategy()
+    return strategy.locate()
 
 
 def all_required_workflow_keys(
-        workflows: Optional[Dict[str, Type[AbsWorkflow]]] = None
+        workflows: Optional[Dict[str, Type[Workflow]]] = None
 ) -> Set[str]:
     """Get all the keys required by the workflows.
 
@@ -304,7 +361,7 @@ def all_required_workflow_keys(
         Set of Keys that workflows are expecting
 
     """
-    workflows = workflows or available_workflows()
+    workflows = workflows or find_builtin_workflows()
     keys: Set[str] = set()
     for speedwagon_workflow in workflows.values():
         keys = keys.union(set(speedwagon_workflow.required_settings_keys))
@@ -376,3 +433,105 @@ def load(self) -> typing.Tuple[str, Dict[str, Any]]:
 
         with open(self.file_name, "r", encoding="utf-8") as file_reader:
             return self.deserialize_data(json.load(file_reader))
+
+
+class AbsPluginFinder(abc.ABC):  # pylint: disable=too-few-public-methods
+    @abc.abstractmethod
+    def locate(self) -> Dict[str, Type[Workflow]]:
+        """Locate plugin workflows."""
+
+
+class EntrypointPluginSearch(AbsPluginFinder, abc.ABC):
+    entrypoint_group = 'speedwagon.plugins'
+
+    def get_entry_points(self) -> metadata.EntryPoints:
+        return metadata.entry_points(group=self.entrypoint_group)
+
+    def load_workflow_from_entry_point(
+            self,
+            entry_point: metadata.EntryPoint
+    ) -> Optional[Tuple[str, Type[speedwagon.Workflow]]]:
+        entry_point_workflow = \
+            typing.cast(Type[speedwagon.Workflow], entry_point.load())
+
+        if not issubclass(entry_point_workflow, speedwagon.Workflow):
+            raise speedwagon.exceptions.InvalidPlugin(
+                f"{entry_point.value} is not a Speedwagon Workflow",
+                entry_point=entry_point
+            )
+        workflow_name: str = entry_point_workflow.name or entry_point.name
+        return workflow_name, entry_point_workflow
+
+    @abc.abstractmethod
+    def should_entrypoint_load(self, entrypoint: metadata.EntryPoint) -> bool:
+        """Get if an entrypoint attempt to be loaded."""
+
+    def locate(self) -> Dict[str, Type[Workflow]]:
+        failed_plugins = []
+        discovered_plugins = {}
+        for entrypoint in self.get_entry_points():
+            if not self.should_entrypoint_load(entrypoint):
+                continue
+            try:
+                result = self.load_workflow_from_entry_point(entrypoint)
+                if result:
+                    workflow_name, entry_point_workflow = result
+                    discovered_plugins[workflow_name] = entry_point_workflow
+            except speedwagon.exceptions.InvalidPlugin as error:
+                failed_plugins.append(error.entry_point.name)
+        if failed_plugins:
+            failed_plugins_string = ",".join(failed_plugins)
+            message = f"{failed_plugins_string} failed to load"
+            raise speedwagon.exceptions.PluginImportError(message)
+        return discovered_plugins
+
+
+class LoadAllPluginSearch(EntrypointPluginSearch):
+    def should_entrypoint_load(self, entrypoint: metadata.EntryPoint) -> bool:
+        return True
+
+
+class LoadWhiteListedPluginsOnly(EntrypointPluginSearch):
+
+    def __init__(self) -> None:
+        self._whitelisted_entry_points: Set[Tuple[str, str]] = set()
+
+    @property
+    def whitelisted_entry_points(self) -> Set[Tuple[str, str]]:
+        return self._whitelisted_entry_points
+
+    @whitelisted_entry_points.setter
+    def whitelisted_entry_points(
+            self,
+            value: Iterable[Tuple[str, str]]
+    ) -> None:
+        for item in value:
+            if len(item) != 2:
+                raise TypeError(
+                    "whitelisted_entry_points include 2 parts: module & name"
+                )
+        self._whitelisted_entry_points = set(value)
+
+    def should_entrypoint_load(self, entrypoint: metadata.EntryPoint) -> bool:
+        return (entrypoint.module, entrypoint.name) in \
+            self.whitelisted_entry_points
+
+
+def find_plugin_workflows(
+        strategy: AbsPluginFinder = LoadAllPluginSearch()
+) -> Dict[str, Type[speedwagon.Workflow]]:
+    return strategy.locate()
+
+
+def find_builtin_workflows() -> Dict[str, Type[Workflow]]:
+    """Locate all workflow class in workflows subpackage.
+
+    This looks for a workflow prefix in the naming.
+
+    Returns:
+        Dictionary of all workflow
+
+    """
+    root = os.path.join(os.path.dirname(__file__), "workflows")
+    finder = WorkflowFinder(root)
+    return finder.locate()
diff --git a/speedwagon/reports.py b/speedwagon/reports.py
index 0848d109f..d537a2694 100644
--- a/speedwagon/reports.py
+++ b/speedwagon/reports.py
@@ -1,15 +1,17 @@
 """Common code to help generate reports for the user."""
-
+import abc
 import functools
 import typing
+from typing import Optional
+import traceback
 
 
 def add_report_borders(
-        func: typing.Callable[..., typing.Optional[str]]
-) -> typing.Callable[..., typing.Optional[str]]:
+        func: typing.Callable[..., Optional[str]]
+) -> typing.Callable[..., Optional[str]]:
     """Create a star character border around text report."""
     @functools.wraps(func)
-    def wrapper(*args, **kwargs) -> typing.Optional[str]:
+    def wrapper(*args, **kwargs) -> Optional[str]:
         report = func(*args, **kwargs)
         if report:
             line_sep = "\n" + "*" * 60
@@ -23,3 +25,43 @@ def wrapper(*args, **kwargs) -> typing.Optional[str]:
                 f"{line_sep}"
         return report
     return wrapper
+
+
+class AbsReporter(abc.ABC):
+    """Abstract base class for generating reports."""
+
+    @abc.abstractmethod
+    def title(self) -> str:
+        """Get the title of the report."""
+
+    @abc.abstractmethod
+    def summary(self) -> str:
+        """Get a summary of the report."""
+
+    @abc.abstractmethod
+    def report(self) -> str:
+        """Get the full detailed report."""
+
+
+class ExceptionReport(AbsReporter):
+    """Report for Exceptions."""
+
+    def __init__(self) -> None:
+        """Generate an exception report class."""
+        super().__init__()
+        self.exception: Optional[BaseException] = None
+
+    def title(self) -> str:
+        """Report the exception type."""
+        return self.exception.__class__.__name__ if self.exception else ""
+
+    def summary(self) -> str:
+        """Report the exception value."""
+        return str(self.exception) if self.exception else ""
+
+    def report(self) -> str:
+        """Generate a report based on the exception traceback."""
+        lines = []
+        if self.exception:
+            lines += traceback.format_tb(self.exception.__traceback__)
+        return "\n".join(lines)
diff --git a/speedwagon/runner_strategies.py b/speedwagon/runner_strategies.py
index a699d8ebc..7c79962a1 100644
--- a/speedwagon/runner_strategies.py
+++ b/speedwagon/runner_strategies.py
@@ -7,6 +7,7 @@
 import enum
 
 import logging
+import os
 import queue
 import sys
 import tempfile
@@ -18,9 +19,13 @@
 from typing import List, Any, Dict, Optional, Type
 
 import speedwagon
+import speedwagon.config
 import speedwagon.exceptions
 from speedwagon import runner
-from speedwagon.job import AbsWorkflow, Workflow
+
+if typing.TYPE_CHECKING:
+    from speedwagon.job import AbsWorkflow, Workflow
+    from speedwagon.config import SettingsData
 
 __all__ = [
     "RunRunner",
@@ -408,11 +413,80 @@ def start(self) -> None:
         self.current_state.start()
 
 
+class AbsTaskGeneratorStrategy(abc.ABC):
+    @abc.abstractmethod
+    def results(self) -> List[Any]:
+        """Results of the job."""
+    @abc.abstractmethod
+    def clear_results(self) -> None:
+        """Clear results."""
+
+    @abc.abstractmethod
+    def iterate_tasks(
+            self,
+            workflow: Workflow,
+            options: typing.Mapping[str, Any],
+            task_scheduler: TaskScheduler
+    ):
+        """Generate and iterate tasks."""
+
+    @abc.abstractmethod
+    def generate_report(
+            self,
+            workflow: Workflow,
+            options: typing.Mapping[str, Any],
+            results: List[Any],
+    ) -> Optional[str]:
+        """Generate Text Report."""
+
+
+class TaskGeneratorStrategy(AbsTaskGeneratorStrategy):
+    def __init__(self):
+        self._results: List[Any] = []
+
+    def results(self) -> List[Any]:
+        return self._results
+
+    def clear_results(self) -> None:
+        self._results.clear()
+
+    def generate_report(
+            self,
+            workflow: Workflow,
+            options: typing.Mapping[str, Any],
+            results: List[Any],
+    ) -> Optional[str]:
+        return workflow.generate_report(results, **options)
+
+    def iterate_tasks(
+            self,
+            workflow: Workflow,
+            options: typing.Mapping[str, Any],
+            task_scheduler: TaskScheduler
+    ):
+        workflow.workflow_options()
+        task_generator = TaskGenerator(
+            workflow,
+            working_directory=task_scheduler.working_directory,
+            options=options,
+            caller=task_scheduler
+        )
+        for task in task_generator.tasks():
+            task_scheduler.total_tasks = task_generator.total_task
+            yield task
+            if task.task_result:
+                self._results.append(task.task_result)
+            task_scheduler.current_task_progress = task_generator.current_task
+
+
 class TaskScheduler:
     """Task scheduler."""
 
     def __init__(self, working_directory: str) -> None:
         """Create a new task scheduler."""
+        self.task_generator_strategy: AbsTaskGeneratorStrategy = \
+            TaskGeneratorStrategy()
+
         self.logger = logging.getLogger(__name__)
         self.working_directory = working_directory
         self.reporter: Optional[
@@ -447,7 +521,7 @@ def iter_tasks(self,
                    workflow: Workflow,
                    options: Dict[str, Any]
                    ) -> typing.Iterable[speedwagon.tasks.Subtask]:
-        """Get sub tasks for a workflow.
+        """Get sub-tasks for a workflow.
 
         Args:
             workflow: Workflow to run
@@ -457,24 +531,18 @@ def iter_tasks(self,
             Yields subtasks for a workflow.
 
         """
-        results: List[Any] = []
+        self.task_generator_strategy.clear_results()
+        yield from \
+            self.task_generator_strategy.iterate_tasks(workflow, options, self)
 
-        task_generator = TaskGenerator(
-            workflow,
-            working_directory=self.working_directory,
-            options=options,
-            caller=self
-        )
-
-        for task in task_generator.tasks():
-            self.total_tasks = task_generator.total_task
-            yield task
-            if task.task_result:
-                results.append(task.task_result)
-            self.current_task_progress = task_generator.current_task
-        report = task_generator.generate_report(results)
-        if report is not None:
-            self.logger.info(task_generator.generate_report(results))
+        report = \
+            self.task_generator_strategy.generate_report(
+                workflow,
+                options,
+                self.task_generator_strategy.results()
+            )
+        if report:
+            self.logger.info(report)
 
     def run_workflow_jobs(
             self,
@@ -573,15 +641,15 @@ def __init__(self, working_directory: str) -> None:
         self.valid_workflows = None
 
     def get_workflow(self, workflow_name: str) -> typing.Type[Workflow]:
-        if self.valid_workflows is not None:
-            if workflow_name is None:
-                raise AssertionError("workflow_name is not set")
+        if self.valid_workflows is None:
             workflow_class = \
-                self.valid_workflows.get(workflow_name)
-
+                    speedwagon.job.available_workflows().get(workflow_name)
+        elif workflow_name is None:
+            raise AssertionError("workflow_name is not set")
         else:
             workflow_class = \
-                speedwagon.job.available_workflows().get(workflow_name)
+                    self.valid_workflows.get(workflow_name)
+
         if workflow_class is None:
             raise AssertionError(f"Workflow not found: {workflow_name}")
         return workflow_class
@@ -596,7 +664,7 @@ def __init__(self) -> None:
         self._background_thread: Optional[threading.Thread] = None
         self.request_more_info = lambda *args, **kwargs: None
         self.global_settings: \
-            Optional[Dict[str, typing.Union[str, bool]]] = None
+            Optional[SettingsData] = None
 
     def __enter__(self) -> "BackgroundJobManager":
         self._exec = None
@@ -621,6 +689,15 @@ def run_job_on_thread(
                 workflow = task_scheduler.get_workflow(workflow_name)(
                     global_settings=options.get("global_settings")
                 )
+                options_backend = speedwagon.config.YAMLWorkflowConfigBackend()
+                strategy = speedwagon.config.StandardConfigFileLocator()
+                backend_yaml = os.path.join(
+                    strategy.get_app_data_dir(),
+                    speedwagon.config.WORKFLOWS_SETTINGS_YML_FILE_NAME
+                )
+                options_backend.workflow = workflow
+                options_backend.yaml_file = backend_yaml
+                workflow.set_options_backend(options_backend)
                 liaison.events.started.wait()
 
                 for task in task_scheduler.iter_tasks(
diff --git a/speedwagon/startup.py b/speedwagon/startup.py
index a1bda1c8c..9d0dd7804 100644
--- a/speedwagon/startup.py
+++ b/speedwagon/startup.py
@@ -14,23 +14,20 @@
 import argparse
 import io
 import json
-import os
 import sys
 import typing
-from typing import Dict, Iterator, Tuple, List, cast, Type, TYPE_CHECKING, \
+from typing import Dict, Iterator, Tuple, List, Type, TYPE_CHECKING, \
     Optional
-import yaml
 
 import speedwagon
 import speedwagon.config
-import speedwagon.exceptions
 
 from speedwagon import job
-
+from speedwagon.exceptions import WorkflowLoadFailure, TabLoadFailure
 
 try:
     from typing import Final
-except ImportError:
+except ImportError:  # pragma: no cover
     from typing_extensions import Final  # type: ignore
 
 if TYPE_CHECKING:
@@ -39,17 +36,12 @@
 
 __all__ = [
     "ApplicationLauncher",
-    "FileFormatError",
 ]
 
 CONFIG_INI_FILE_NAME: Final[str] = "config.ini"
 TABS_YML_FILE_NAME:  Final[str] = "tabs.yml"
 
 
-class FileFormatError(Exception):
-    """Exception is thrown when Something wrong with the contents of a file."""
-
-
 def parse_args() -> argparse.ArgumentParser:
     """Parse command line arguments."""
     return speedwagon.config.CliArgsSetter.get_arg_parser()
@@ -69,17 +61,6 @@ def __init__(
         """
         self.all_workflows = all_workflows
 
-    @staticmethod
-    def read_yml_file(yaml_file: str) -> Dict[str,  List[str]]:
-        """Read the contents of the yml file."""
-        with open(yaml_file, encoding="utf-8") as file_handler:
-            tabs_config_data = yaml.load(file_handler.read(),
-                                         Loader=yaml.SafeLoader)
-
-        if not isinstance(tabs_config_data, dict):
-            raise FileFormatError("Failed to parse file")
-        return tabs_config_data
-
     def _get_tab_items(self,
                        tab: List[str],
                        tab_name: str) -> Dict[str, Type[job.Workflow]]:
@@ -97,52 +78,60 @@ def _get_tab_items(self,
                     f"tab {tab_name}", file=sys.stderr)
         return new_tab_items
 
-    def load_custom_tabs(self, yaml_file: str) -> Iterator[Tuple[str, dict]]:
+    def _load_workflow(self, workflow_name: str):
+        try:
+            workflow = self.all_workflows[workflow_name]
+            if workflow.active is False:
+                print("workflow not active")
+        except KeyError as tab_error:
+            raise WorkflowLoadFailure from tab_error
+        return workflow
+
+    def load_custom_tabs(
+            self,
+            strategy: speedwagon.config.AbsTabsConfigDataManagement
+    ) -> Iterator[Tuple[str, dict]]:
         """Get custom tabs data from config yaml.
 
         Args:
-            yaml_file: file path to a yaml file containing custom.
+            strategy: strategy for retrieving the tab data.
 
         Yields:
-            Yields a tuple containing the name of the tab and the containing
-                workflows.
-        Notes:
-            Failure to load will only a print message to standard error.
-
+            Yields a tuple containing the name of the tab and the
+                containing workflows.
         """
         try:
-            tabs_config_data = self.read_yml_file(yaml_file)
-            if tabs_config_data:
-                tabs_config_data = cast(Dict[str, List[str]], tabs_config_data)
-                for tab_name in tabs_config_data:
-                    try:
-                        new_tab = tabs_config_data.get(tab_name)
-                        if new_tab is not None:
-                            yield tab_name, \
-                                  self._get_tab_items(new_tab, tab_name)
-
-                    except TypeError as tab_error:
-                        print(
-                            f"Error loading tab '{tab_name}'. "
-                            f"Reason: {tab_error}",
-                            file=sys.stderr
-                        )
-                        continue
-
-        except FileNotFoundError as error:
+            for tab_entity in strategy.data():
+                try:
+                    new_tab_items = {}
+                    for item_name in tab_entity.workflow_names:
+                        try:
+                            workflow = self._load_workflow(item_name)
+                        except WorkflowLoadFailure as error:
+                            print(
+                                f"Unable to load workflow '{item_name}'. "
+                                f"Reason: {error}",
+                                file=sys.stderr
+                            )
+                            continue
+                        new_tab_items[item_name] = workflow
+
+                    yield tab_entity.tab_name, new_tab_items
+                except TabLoadFailure as error:
+                    print(
+                        f"Custom tab {tab_entity.tab_name} failed to load. "
+                        f"Reason: {error}",
+                        file=sys.stderr
+                    )
+                    raise
+        except TabLoadFailure as error:
             print(
-                f"Custom tabs file not found. Reason: {error}",
+                f"Custom tabs failed to load. Reason: {error}",
                 file=sys.stderr
             )
         except AttributeError as error:
             print(
-                f"Custom tabs file failed to load. Reason: {error}",
-                file=sys.stderr
-            )
-
-        except yaml.YAMLError as error:
-            print(
-                f"{yaml_file} file failed to load. Reason: {error}",
+                f"Custom tabs failed to load. Reason: {error}",
                 file=sys.stderr
             )
 
@@ -153,7 +142,15 @@ def get_custom_tabs(
 ) -> Iterator[Tuple[str, dict]]:
     """Load custom tab yaml file."""
     getter = CustomTabsFileReader(all_workflows)
-    yield from getter.load_custom_tabs(yaml_file)
+    try:
+        yield from getter.load_custom_tabs(
+            strategy=speedwagon.config.CustomTabsYamlConfig(yaml_file)
+        )
+    except FileNotFoundError as error:
+        print(
+            f"Custom tabs file not found. Reason: {error}",
+            file=sys.stderr
+        )
 
 
 class ApplicationLauncher:
@@ -161,29 +158,6 @@ class ApplicationLauncher:
 
     .. versionadded:: 0.2.0
        Added ApplicationLauncher for launching speedwagon in different ways.
-
-    Examples:
-    .. testsetup::
-
-        from speedwagon.workflows.workflow_capture_one_to_dl_compound_and_dl import CaptureOneToDlCompoundAndDLWorkflow  # noqa: E501 pylint: disable=line-too-long
-        from speedwagon.frontend.qtwidgets.gui_startup import SingleWorkflowLauncher  # noqa: E501 pylint: disable=line-too-long
-
-
-    .. testcode::
-       :skipif: True
-
-       >>> startup_strategy = SingleWorkflowLauncher()
-       >>> startup_strategy.set_workflow(
-       ...      CaptureOneToDlCompoundAndDLWorkflow()
-       ... )
-       >>> startup_strategy.options = {
-       ...      "Input": "source/images/",
-       ...      "Package Type": "Capture One",
-       ...      "Output Digital Library": "output/dl",
-       ...      "Output HathiTrust": "output/ht"
-       ... }
-       >>> app = ApplicationLauncher(strategy=startup_strategy)
-       >>> app.run()
     """
 
     def __init__(self, strategy: Optional[AbsStarter] = None) -> None:
@@ -230,12 +204,16 @@ def run(self):
 
 
 class RunCommand(SubCommand):
+    def get_gui_strategy(
+            self
+    ) -> speedwagon.frontend.qtwidgets.gui_startup.SingleWorkflowJSON:
+        from speedwagon import frontend
+        return frontend.qtwidgets.gui_startup.SingleWorkflowJSON(app=None)
+
     def json_startup(self) -> None:
         try:
-            from speedwagon import frontend
-            startup_strategy = \
-                frontend.qtwidgets.gui_startup.SingleWorkflowJSON(app=None)
-        except AttributeError:
+            startup_strategy = self.get_gui_strategy()
+        except ImportError:
             startup_strategy = SingleWorkflowJSON()
 
         startup_strategy.global_settings = self.global_settings
@@ -259,13 +237,14 @@ def run(self):
 
 
 def get_global_options():
-    platform_settings = speedwagon.config.get_platform_settings()
-
-    config_file = os.path.join(
-        platform_settings.get_app_data_directory(),
-        CONFIG_INI_FILE_NAME
-    )
-    return speedwagon.config.ConfigLoader(config_file).get_settings()
+    config_locator = speedwagon.config.StandardConfigFileLocator()
+    loader = speedwagon.config.MixedConfigLoader()
+    loader.resolution_strategy_order = [
+        speedwagon.config.DefaultsSetter(),
+        speedwagon.config.ConfigFileSetter(config_locator.get_config_file()),
+        speedwagon.config.CliArgsSetter()
+    ]
+    return loader.get_settings().get("GLOBAL", {})
 
 
 def run_command(
@@ -287,10 +266,6 @@ def run_command(
 
 
 class AbsStarter(metaclass=abc.ABCMeta):
-    config_file: str
-    tabs_file: str
-    user_data_dir: str
-    app_data_dir: str
 
     @abc.abstractmethod
     def run(self) -> int:
@@ -306,12 +281,14 @@ def __init__(self) -> None:
         super().__init__()
         self.options: typing.Optional[typing.Dict[str, typing.Any]] = None
         self.global_settings = None
+        self.workflow = None
 
     def run(self) -> int:
-        speedwagon.simple_api_run_workflow(
-            self.workflow,
-            self.options,
-        )
+        if self.workflow:
+            speedwagon.simple_api_run_workflow(
+                self.workflow,
+                self.options,
+            )
         return 0
 
     def load(self, file_pointer: io.TextIOBase) -> None:
diff --git a/speedwagon/utils.py b/speedwagon/utils.py
index 2e51f880a..1b1da2ccd 100644
--- a/speedwagon/utils.py
+++ b/speedwagon/utils.py
@@ -3,6 +3,7 @@
 from typing import Iterator, Callable
 
 import logging
+from logging.handlers import BufferingHandler
 from contextlib import contextmanager
 
 
@@ -20,8 +21,7 @@ def log_config(
     """
     try:
         log_handler: logging.Handler
-        from speedwagon.frontend.qtwidgets.logging_helpers import GuiLogHandler
-        log_handler = GuiLogHandler(callback)
+        log_handler = CallbackLogHandler(callback)
     except ImportError:
         log_handler = logging.StreamHandler()
 
@@ -30,3 +30,19 @@ def log_config(
         yield
     finally:
         logger.removeHandler(log_handler)
+
+
+class CallbackLogHandler(BufferingHandler):
+    """Logger that runs a callback."""
+
+    def __init__(
+            self,
+            callback: Callable[[str], None],
+    ) -> None:
+        """Create a log handler for callbacks."""
+        super().__init__(capacity=5)
+        self.callback = callback
+
+    def emit(self, record: logging.LogRecord) -> None:
+        """Emit logged message to callback function."""
+        self.callback(logging.Formatter().format(record))
diff --git a/speedwagon/workflow.py b/speedwagon/workflow.py
index d46822173..74a9ea4c6 100644
--- a/speedwagon/workflow.py
+++ b/speedwagon/workflow.py
@@ -2,14 +2,22 @@
 
 import abc
 import json
+import os
 from typing import Any, Dict, List, Optional, Union
 
+import speedwagon.config
+import speedwagon.job
+
+UserDataType = Union[str, bool, int, None]
+UserData = Dict[str, UserDataType]
+
 
 class AbsOutputOptionDataType(abc.ABC):
     """Base case for generating user option types."""
 
     label: str
     widget_name: str
+    setting_name: str
     required: bool
 
     def __init_subclass__(cls) -> None:
@@ -26,13 +34,16 @@ def __init__(self, label: str, required: bool) -> None:
         self.value: Optional[Union[str, int, bool]] = None
         self.placeholder_text: Optional[str] = None
         self.required = required
+        self.setting_name: Optional[str] = None
+        self.default_value: Optional[Union[str, int, bool]] = None
 
     def serialize(self) -> Dict[str, Any]:
         """Serialize the data."""
         data = {
             "widget_type": self.widget_name,
             "label": self.label,
-            "required": self.required
+            "required": self.required,
+            "setting_name": self.setting_name or self.label.replace(" ", "_")
         }
         if self.value is not None:
             data['value'] = self.value
@@ -125,3 +136,24 @@ def serialize(self) -> Dict[str, Any]:
         if self.value is None:
             data['value'] = False
         return data
+
+
+def initialize_workflows() -> List[speedwagon.job.Workflow]:
+    """Initialize workflow for use."""
+    config_strategy = speedwagon.config.StandardConfigFileLocator()
+    workflows = []
+    backend_yaml = os.path.join(
+        config_strategy.get_app_data_dir(),
+        speedwagon.config.WORKFLOWS_SETTINGS_YML_FILE_NAME
+    )
+    for workflow_klass in sorted(
+            speedwagon.job.available_workflows().values(),
+            key=lambda workflow: workflow.name
+    ):
+        config_backend = speedwagon.config.YAMLWorkflowConfigBackend()
+        workflow = workflow_klass()
+        config_backend.workflow = workflow
+        config_backend.yaml_file = backend_yaml
+        workflow.set_options_backend(config_backend)
+        workflows.append(workflow)
+    return workflows
diff --git a/speedwagon/workflows/workflow_batch_to_HathiTrust_TIFF.py b/speedwagon/workflows/workflow_batch_to_HathiTrust_TIFF.py
index 29708c9fd..8036a778c 100644
--- a/speedwagon/workflows/workflow_batch_to_HathiTrust_TIFF.py
+++ b/speedwagon/workflows/workflow_batch_to_HathiTrust_TIFF.py
@@ -19,6 +19,7 @@
 from speedwagon.workflows import workflow_get_marc
 if typing.TYPE_CHECKING:
     from speedwagon.workflow import AbsOutputOptionDataType
+    from speedwagon.config import SettingsData
 
 __all__ = ['CaptureOneBatchToHathiComplete']
 
@@ -41,9 +42,10 @@ class CaptureOneBatchToHathiComplete(speedwagon.Workflow):
                   "new files will be written."
     active = False
 
-    def __init__(self,
-                 global_settings: Optional[Dict[str, str]] = None
-                 ) -> None:
+    def __init__(
+            self,
+            global_settings: Optional[SettingsData] = None
+    ) -> None:
         """Convert CaptureOne Batch to HathiTrust TIFF Complete Package.
 
         Args:
@@ -90,7 +92,7 @@ def discover_task_metadata(self, initial_results: List[
                 )
         return tasks_metadata
 
-    def get_user_options(self) -> List[AbsOutputOptionDataType]:
+    def job_options(self) -> List[AbsOutputOptionDataType]:
         """Request identifier type, source, and destination."""
         supported_identifier_types: List[str] = [
             "Bibid",
diff --git a/speedwagon/workflows/workflow_capture_one_to_dl_compound_and_dl.py b/speedwagon/workflows/workflow_capture_one_to_dl_compound_and_dl.py
index 632057731..c18536a37 100644
--- a/speedwagon/workflows/workflow_capture_one_to_dl_compound_and_dl.py
+++ b/speedwagon/workflows/workflow_capture_one_to_dl_compound_and_dl.py
@@ -102,7 +102,7 @@ class CaptureOneToDlCompoundAndDLWorkflow(Workflow):
                   "HathiTrust."
     active = True
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request user options.
diff --git a/speedwagon/workflows/workflow_completeness.py b/speedwagon/workflows/workflow_completeness.py
index 6809dab2e..2ff548ae2 100644
--- a/speedwagon/workflows/workflow_completeness.py
+++ b/speedwagon/workflows/workflow_completeness.py
@@ -40,7 +40,7 @@ class CompletenessWorkflow(speedwagon.job.Workflow):
                   "valid. (This workflow provides console feedback, but " \
                   "doesn’t write new files as output)."
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Request user settings for which checks to be performed."""
         source = workflow.DirectorySelect("Source")
 
diff --git a/speedwagon/workflows/workflow_convertCaptureOnePreservationToDigitalLibJP2.py b/speedwagon/workflows/workflow_convertCaptureOnePreservationToDigitalLibJP2.py
index a3ac9d2d3..ec5b123b4 100644
--- a/speedwagon/workflows/workflow_convertCaptureOnePreservationToDigitalLibJP2.py
+++ b/speedwagon/workflows/workflow_convertCaptureOnePreservationToDigitalLibJP2.py
@@ -114,7 +114,7 @@ def filter_only_tif_files(item: os.DirEntry) -> bool:
 
         return jobs
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request use settings for source path."""
diff --git a/speedwagon/workflows/workflow_get_marc.py b/speedwagon/workflows/workflow_get_marc.py
index 2a11cee12..8845e25d3 100644
--- a/speedwagon/workflows/workflow_get_marc.py
+++ b/speedwagon/workflows/workflow_get_marc.py
@@ -1,8 +1,10 @@
 """Generating MARC XML files by retrieving from a server."""
+from __future__ import annotations
 import abc
 import functools
 import os
 import re
+import typing
 from copy import deepcopy
 
 try:  # pragma: no cover
@@ -12,7 +14,7 @@
 
 
 from typing import List, Any, Optional, Union, Sequence, Dict, Set, Tuple, \
-    Iterator, Collection
+    Iterator, Collection, TYPE_CHECKING
 
 from xml.dom import minidom
 import xml.etree.ElementTree as ET
@@ -23,7 +25,9 @@
 import speedwagon
 from speedwagon.exceptions import MissingConfiguration, SpeedwagonException
 from speedwagon import reports, validators, workflow
-from speedwagon.job import Workflow
+
+if TYPE_CHECKING:
+    from speedwagon.workflow import AbsOutputOptionDataType
 
 __all__ = ['GenerateMarcXMLFilesWorkflow']
 
@@ -41,7 +45,7 @@
 BIBID_PATTERN = re.compile(r"^(?P<identifier>[0-9]*)")
 
 
-class GenerateMarcXMLFilesWorkflow(Workflow):
+class GenerateMarcXMLFilesWorkflow(speedwagon.Workflow):
     """Generate Marc XML files.
 
     .. versionchanged:: 0.1.5
@@ -66,26 +70,7 @@ class GenerateMarcXMLFilesWorkflow(Workflow):
                   "files from the Library."
     required_settings_keys: Set[str] = {"getmarc_server_url"}
 
-    def __init__(
-            self,
-            global_settings: Optional[Dict[str, str]] = None
-    ) -> None:
-        """Generate Marc XML files.
-
-        Args:
-            global_settings:
-                Settings that could affect the way the workflow runs.
-        """
-        super().__init__()
-
-        if global_settings is not None:
-            self.global_settings = global_settings
-        for k in GenerateMarcXMLFilesWorkflow.required_settings_keys:
-            value = self.global_settings.get(k)
-            if value is None:
-                raise MissingConfiguration(f"Missing value for {k}")
-
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[AbsOutputOptionDataType]:
         """Request user options.
 
         User Options include:
@@ -133,6 +118,12 @@ def filter_bib_id_folders(cls, item: os.DirEntry) -> bool:
 
         return True
 
+    def get_marc_server(self) -> Optional[str]:
+        return typing.cast(
+            Optional[str],
+            self.get_workflow_configuration_value('Getmarc server url')
+        )
+
     def discover_task_metadata(
             self,
             initial_results: Sequence[Any],
@@ -150,9 +141,9 @@ def discover_task_metadata(
             list of dictionaries of job metadata
 
         """
-        server_url = self.global_settings.get("getmarc_server_url")
+        server_url = self.get_marc_server()
         if server_url is None:
-            raise MissingConfiguration("getmarc_server_url")
+            raise MissingConfiguration("Getmarc server url is not set")
 
         return [{
             "directory": {
@@ -318,6 +309,18 @@ def _get_identifier_volume(job_args) -> Tuple[str, Union[str, None]]:
         results = match.groupdict()
         return results['identifier'], results.get('volume')
 
+    def workflow_options(self) -> List[AbsOutputOptionDataType]:
+        """Set the settings for get marc workflow.
+
+        This needs the getmarc server url.
+        """
+        return [
+            speedwagon.workflow.TextLineEditData(
+                'Getmarc server url',
+                required=True
+            ),
+        ]
+
 
 class AbsMarcFileStrategy(abc.ABC):
     """Base class for retrieving MARC records from a server."""
diff --git a/speedwagon/workflows/workflow_hathi_limited_to_dl_compound.py b/speedwagon/workflows/workflow_hathi_limited_to_dl_compound.py
index 0743a5b1e..0ea37fb4e 100644
--- a/speedwagon/workflows/workflow_hathi_limited_to_dl_compound.py
+++ b/speedwagon/workflows/workflow_hathi_limited_to_dl_compound.py
@@ -49,7 +49,7 @@ def create_new_task(
                              dst=job_args['destination'])
         )
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Get user options for input and output directories."""
diff --git a/speedwagon/workflows/workflow_hathiprep.py b/speedwagon/workflows/workflow_hathiprep.py
index bd02daedf..475cebaa9 100644
--- a/speedwagon/workflows/workflow_hathiprep.py
+++ b/speedwagon/workflows/workflow_hathiprep.py
@@ -29,7 +29,7 @@ class HathiPrepWorkflow(speedwagon.Workflow):
                   "viewer." \
 
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Get user options.
diff --git a/speedwagon/workflows/workflow_make_checksum.py b/speedwagon/workflows/workflow_make_checksum.py
index b2eb24ca2..a618041a3 100644
--- a/speedwagon/workflows/workflow_make_checksum.py
+++ b/speedwagon/workflows/workflow_make_checksum.py
@@ -132,7 +132,7 @@ def generate_report(cls,
 
         return "\n".join(report_lines)
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request directory input setting from user."""
@@ -186,7 +186,7 @@ def discover_task_metadata(
                     jobs.append(job)
         return jobs
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request input directory value from the user."""
diff --git a/speedwagon/workflows/workflow_make_jp2.py b/speedwagon/workflows/workflow_make_jp2.py
index 9f702f522..0d2e6fc6f 100644
--- a/speedwagon/workflows/workflow_make_jp2.py
+++ b/speedwagon/workflows/workflow_make_jp2.py
@@ -110,7 +110,7 @@ class MakeJp2Workflow(job.Workflow):
 
     active = True
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Get user options.
diff --git a/speedwagon/workflows/workflow_medusa_preingest.py b/speedwagon/workflows/workflow_medusa_preingest.py
index a294c8eb1..421d79f7a 100644
--- a/speedwagon/workflows/workflow_medusa_preingest.py
+++ b/speedwagon/workflows/workflow_medusa_preingest.py
@@ -1,12 +1,15 @@
 """Workflow for Medusa Preingest.
 
 Added on 3/30/2022
+
+.. versionadded:: 0.3.0 added option for removing Thumbs.db files.
 """
 
+import abc
 import os
 import typing
 from typing import List, Any, Dict, Optional, Set, Iterator, Union, Callable
-
+from pathlib import Path
 import speedwagon
 from speedwagon import workflow, tasks
 from speedwagon.frontend import interaction
@@ -35,6 +38,7 @@ class MedusaPreingestCuration(speedwagon.Workflow):
         """
 -  Locates and deletes file that start with ._ (dot underscore)
 -  Locates and deletes .DS_Store files
+-  Locates and deletes Thumbs.db files
 -  Locates and deletes Capture One files
     """.strip()
 
@@ -113,7 +117,7 @@ def sort_item_data(data: List[str]) -> Dict[str, List[str]]:
             "directories": dirs,
         }
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Get which types of files to search for."""
         root_directory = speedwagon.workflow.DirectorySelect("Path")
 
@@ -133,6 +137,12 @@ def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
             )
         delete_ds_store.value = True
 
+        delete_thumbs_db = \
+            speedwagon.workflow.BooleanSelect(
+                "Locate and delete Thumbs.db files"
+            )
+        delete_thumbs_db.value = True
+
         delete_capture_one = \
             speedwagon.workflow.BooleanSelect(
                 "Locate and delete Capture One files"
@@ -144,6 +154,7 @@ def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
             include_subdirectories,
             delete_dot_underscore,
             delete_ds_store,
+            delete_thumbs_db,
             delete_capture_one
         ]
 
@@ -194,6 +205,46 @@ def create_new_task(self, task_builder: tasks.TaskBuilder,
             )
 
 
+class AbsChecker(abc.ABC):  # pylint: disable=R0903
+
+    @abc.abstractmethod
+    def is_valid(self, path: Path) -> bool:
+        """Is path valid."""
+
+
+class AbsPathItemDecision(abc.ABC):  # pylint: disable=R0903
+    @abc.abstractmethod
+    def is_offending(self, path: Path) -> bool:
+        """Get if file is offending or not."""
+
+
+class DsStoreChecker(AbsChecker):  # pylint: disable=R0903
+    def is_valid(self, path: Path) -> bool:
+        return path.name != ".DS_Store"
+
+
+class ThumbsDbChecker(AbsChecker):  # pylint: disable=R0903
+    def is_valid(self, path: Path) -> bool:
+        return path.name != "Thumbs.db"
+
+
+class DotUnderScoreChecker(AbsChecker):  # pylint: disable=R0903
+    def is_valid(self, path: Path) -> bool:
+        return not path.name.startswith("._")
+
+
+class OffendingPathDecider(AbsPathItemDecision):
+
+    def __init__(self) -> None:
+        self._checkers: List[AbsChecker] = []
+
+    def add_checker(self, value: AbsChecker) -> None:
+        self._checkers.append(value)
+
+    def is_offending(self, path: Path) -> bool:
+        return any(not checker.is_valid(path) for checker in self._checkers)
+
+
 class FindOffendingFiles(tasks.Subtask):
 
     def __init__(self, **user_args) -> None:
@@ -202,14 +253,18 @@ def __init__(self, **user_args) -> None:
         self.root: str = user_args['Path']
         self._include_subdirectory = user_args['Include Subdirectories']
 
-        self._locate_dot_underscore: bool = \
-            user_args['Locate and delete dot underscore files']
-
-        self._locate_ds_store: bool = \
-            user_args['Locate and delete .DS_Store files']
-
         self._locate_capture_one: bool = \
             user_args['Locate and delete Capture One files']
+        self.file_deciding_strategy = OffendingPathDecider()
+
+        if user_args['Locate and delete dot underscore files']:
+            self.file_deciding_strategy.add_checker(DotUnderScoreChecker())
+
+        if user_args['Locate and delete .DS_Store files']:
+            self.file_deciding_strategy.add_checker(DsStoreChecker())
+
+        if user_args['Locate and delete Thumbs.db files']:
+            self.file_deciding_strategy.add_checker(ThumbsDbChecker())
 
     def task_description(self) -> Optional[str]:
         return f"Searching {self.root}"
@@ -255,17 +310,7 @@ def locate_offending_subdirectories(self, root_dir: str) -> Iterator[str]:
 
     def locate_offending_files(self, root_dir: str) -> Iterator[str]:
         for item in filter(lambda i: i.is_file(), os.scandir(root_dir)):
-            if all([
-                self._locate_dot_underscore,
-                item.name.startswith("._")
-            ]):
-                yield item.path
-                continue
-
-            if all([
-                self._locate_ds_store,
-                item.name == ".DS_Store"
-            ]):
+            if self.file_deciding_strategy.is_offending(Path(item.path)):
                 yield item.path
 
     def locate_offending_files_and_folders(
diff --git a/speedwagon/workflows/workflow_ocr.py b/speedwagon/workflows/workflow_ocr.py
index 8942d6fa9..7bcb7a9ab 100644
--- a/speedwagon/workflows/workflow_ocr.py
+++ b/speedwagon/workflows/workflow_ocr.py
@@ -1,5 +1,5 @@
 """Workflow for performing OCR on image files."""
-
+from __future__ import annotations
 import io
 import os
 import typing
@@ -7,10 +7,20 @@
 from typing import List, Any, Optional, Iterator, Dict
 import contextlib
 from uiucprescon import ocr
+
 import speedwagon
 import speedwagon.workflow
 
-from speedwagon.exceptions import MissingConfiguration, SpeedwagonException
+from speedwagon.exceptions import \
+    MissingConfiguration, \
+    InvalidConfiguration, \
+    SpeedwagonException, \
+    JobCancelled
+
+TESSERACT_PATH_LABEL = "Tesseract data file location"
+
+if typing.TYPE_CHECKING:
+    from speedwagon.config import SettingsData
 
 __all__ = ['OCRWorkflow']
 
@@ -46,15 +56,10 @@ def __init__(self, *args, **kwargs) -> None:
         """Create a OCR Workflow."""
         super().__init__(*args, **kwargs)
         self.global_settings = kwargs.get('global_settings', {})
-        self.tessdata_path = self._get_tessdata_dir(args, self.global_settings)
-
-        if self.tessdata_path is None:
-            raise MissingConfiguration(
-                "Required setting not configured: tessdata"
-            )
-
-        if not os.path.exists(self.tessdata_path):
-            os.mkdir(self.tessdata_path)
+        try:
+            tessdata_path = self.get_tesseract_path()
+        except AttributeError:
+            tessdata_path = ""
 
         description = \
             "Create OCR text files for images. \n" \
@@ -66,11 +71,10 @@ def __init__(self, *args, **kwargs) -> None:
             "\n" \
             "Adding Additional Languages:\n" \
             "    To modify the available languages, place " \
-            "Tesseract traineddata files for " \
-            f"version {ocr.Engine(self.tessdata_path).get_version()} " \
+            "Tesseract traineddata files" \
             "into the following directory:\n" \
             "\n" \
-            f"{self.tessdata_path}.\n" \
+            f"{tessdata_path}.\n" \
             "\n" \
             "Note:\n" \
             "    It's important to use the correct version of the " \
@@ -82,9 +86,10 @@ def __init__(self, *args, **kwargs) -> None:
         self.set_description(description)
 
     @staticmethod
-    def _get_tessdata_dir(args,
-                          global_settings: Dict[str, str]
-                          ) -> Optional[str]:
+    def _get_tessdata_dir(
+            args,
+            global_settings: SettingsData
+    ) -> Optional[str]:
 
         tessdata_path = global_settings.get("tessdata")
         if tessdata_path is None:
@@ -92,7 +97,7 @@ def _get_tessdata_dir(args,
                 tessdata_path = args[0].get('tessdata')
             except IndexError:
                 pass
-        return tessdata_path
+        return typing.cast(Optional[str], tessdata_path)
 
     @classmethod
     def set_description(cls, text: str) -> None:
@@ -105,16 +110,20 @@ def discover_task_metadata(self,
                                additional_data: Dict[str, Any],
                                **user_args: str) -> List[dict]:
         """Create OCR task metadata for each file located."""
-        if self.tessdata_path is not None and \
-                not os.path.exists(self.tessdata_path):
-            raise MissingConfiguration("tessdata_path")
+        tessdata_path = self.get_tesseract_path()
+        if tessdata_path is None:
+            raise InvalidConfiguration("Tesseract data file location not set")
+
+        if os.path.exists(tessdata_path) is False:
+            raise InvalidConfiguration(
+                f'Tesseract data file location "{tessdata_path}" '
+                f'does not exist'
+            )
+
         new_tasks = []
 
         for result in initial_results:
             for image_file in result.data:
-                image_path = os.path.dirname(image_file)
-                base_name = os.path.splitext(os.path.basename(image_file))[0]
-                ocr_file_name = f"{base_name}.txt"
                 for key, value in ocr.LANGUAGE_CODES.items():
                     if value == user_args["Language"]:
                         language_code = key
@@ -125,10 +134,11 @@ def discover_task_metadata(self,
                         f"Unable to look up language code for {language}"
                     )
 
+                base_name = os.path.splitext(os.path.basename(image_file))[0]
                 new_task = {
                     "source_file_path": image_file,
-                    "destination_path": image_path,
-                    "output_file_name": ocr_file_name,
+                    "destination_path": os.path.dirname(image_file),
+                    "output_file_name": f"{base_name}.txt",
                     "lang_code": language_code
                 }
                 new_tasks.append(new_task)
@@ -142,12 +152,17 @@ def create_new_task(self,
         destination_path = job_args["destination_path"]
         ocr_file_name = job_args["output_file_name"]
         lang_code = job_args["lang_code"]
+        tessdata_path = self.get_tesseract_path()
+        if tessdata_path is None:
+            raise MissingConfiguration(
+                "Tesseract data file location is not set"
+            )
 
         ocr_generation_task = GenerateOCRFileTask(
                 source_image=image_file,
                 out_text_file=os.path.join(destination_path, ocr_file_name),
                 lang=lang_code,
-                tesseract_path=self.tessdata_path
+                tesseract_path=tessdata_path
             )
         task_builder.add_subtask(ocr_generation_task)
 
@@ -157,7 +172,10 @@ def initial_task(self,
         """Create a task to locate appropriate files."""
         root = user_args['Path']
         file_type = user_args["Image File Type"]
-        file_extension = self.get_file_extension(file_type)
+        try:
+            file_extension = self.get_file_extension(file_type)
+        except KeyError as exc:
+            raise JobCancelled(f"Invalid Image Type: {exc}") from exc
 
         task_builder.add_subtask(
             FindImagesTask(root, file_extension=file_extension)
@@ -168,11 +186,12 @@ def get_file_extension(cls, file_type: str) -> str:
         """Identify file type extension."""
         return cls.SUPPORTED_IMAGE_TYPES[file_type]
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request use settings for OCR job."""
         package_type = speedwagon.workflow.ChoiceSelection("Image File Type")
+        package_type.required = True
         package_type.placeholder_text = "Select Image Format"
         for file_type in OCRWorkflow.SUPPORTED_IMAGE_TYPES:
             package_type.add_selection(file_type)
@@ -181,8 +200,15 @@ def get_user_options(
         language_type.placeholder_text = "Select Language"
         tessdata_path = self.get_tesseract_path()
 
-        for lang in self.get_available_languages(
-                path=typing.cast(str, tessdata_path)
+        def full_name(code: str) -> str:
+            language_name = ocr.LANGUAGE_CODES.get(code)
+            return '' if language_name is None else language_name
+
+        for lang in sorted(
+                self.get_available_languages(
+                    path=typing.cast(str, tessdata_path)
+                ),
+                key=full_name
         ):
             fullname = ocr.LANGUAGE_CODES.get(lang)
             if fullname is None:
@@ -197,10 +223,12 @@ def get_user_options(
             package_root_option
         ]
 
-    def get_tesseract_path(self):
+    def get_tesseract_path(self) -> Optional[str]:
         """Get the path to the tesseract data files."""
-        self.tessdata_path = self.global_settings.get("tessdata")
-        return self.tessdata_path
+        return typing.cast(
+            Optional[str],
+            self.get_workflow_configuration_value(TESSERACT_PATH_LABEL)
+        )
 
     @staticmethod
     def get_available_languages(path: str) -> Iterator[str]:
@@ -231,9 +259,9 @@ def validate_user_options(**user_args: str) -> bool:
             raise ValueError("No path selected")
         if not os.path.exists(path):
             raise ValueError(f"Unable to locate {path}.")
-
         if not os.path.isdir(path):
             raise ValueError(f"Input not a valid directory {path}.")
+
         return True
 
     @classmethod
@@ -265,6 +293,32 @@ def filter_ocr_gen_tasks(
 
         return list(filter(filter_ocr_gen_tasks, results))
 
+    @staticmethod
+    def default_tesseract_data_path() -> str:
+        """Get the default path to tessdata files."""
+        return os.path.join(
+            speedwagon.config.StandardConfigFileLocator().get_user_data_dir(),
+            "tessdata"
+        )
+
+    def workflow_options(
+        self
+    ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
+        """Set the settings for get marc workflow.
+
+        This needs the path to the tesseract data.
+        """
+        tesseract_path = \
+            speedwagon.workflow.DirectorySelect(
+                label=TESSERACT_PATH_LABEL
+            )
+
+        tesseract_path.required = True
+        tesseract_path.default_value = self.default_tesseract_data_path()
+        return [
+            tesseract_path
+        ]
+
 
 class FindImagesTask(speedwagon.tasks.Subtask):
     name = "Finding Images"
diff --git a/speedwagon/workflows/workflow_validate_hathi_metadata.py b/speedwagon/workflows/workflow_validate_hathi_metadata.py
index 4db018612..c14d514fc 100644
--- a/speedwagon/workflows/workflow_validate_hathi_metadata.py
+++ b/speedwagon/workflows/workflow_validate_hathi_metadata.py
@@ -40,7 +40,7 @@ def discover_task_metadata(self,
         })
         return jobs
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Request input setting from user."""
         return [
             workflow.DirectorySelect("Input")
diff --git a/speedwagon/workflows/workflow_validate_metadata.py b/speedwagon/workflows/workflow_validate_metadata.py
index e193f5301..f250d80b0 100644
--- a/speedwagon/workflows/workflow_validate_metadata.py
+++ b/speedwagon/workflows/workflow_validate_metadata.py
@@ -65,7 +65,7 @@ def initial_task(
             )
         )
 
-    def get_user_options(
+    def job_options(
             self
     ) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
         """Request user options.
@@ -193,7 +193,7 @@ def work(self) -> bool:
         for root, _, files in os.walk(self._root):
             for file_name in files:
                 _, ext = os.path.splitext(file_name)
-                if not ext.lower() in self._profile.valid_extensions:
+                if ext.lower() not in self._profile.valid_extensions:
                     continue
                 image_file = os.path.join(root, file_name)
                 self.log(f"Found {image_file}")
diff --git a/speedwagon/workflows/workflow_verify_checksums.py b/speedwagon/workflows/workflow_verify_checksums.py
index 8663f2c47..ee4549f18 100644
--- a/speedwagon/workflows/workflow_verify_checksums.py
+++ b/speedwagon/workflows/workflow_verify_checksums.py
@@ -89,7 +89,7 @@ def discover_task_metadata(self,
                 jobs.append(new_job)
         return jobs
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Request user options.
 
         User Options include:
@@ -372,7 +372,7 @@ def discover_task_metadata(self,
             jobs.append(new_job)
         return jobs
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Request user options.
 
         User Options include:
diff --git a/speedwagon/workflows/workflow_zip_packages.py b/speedwagon/workflows/workflow_zip_packages.py
index efc0bb7dc..b44269038 100644
--- a/speedwagon/workflows/workflow_zip_packages.py
+++ b/speedwagon/workflows/workflow_zip_packages.py
@@ -60,7 +60,7 @@ def validate_user_options(**user_args: str) -> bool:
 
         return True
 
-    def get_user_options(self) -> List[workflow.AbsOutputOptionDataType]:
+    def job_options(self) -> List[workflow.AbsOutputOptionDataType]:
         """Request user settings for source and output paths."""
         source = workflow.DirectorySelect("Source")
         output = workflow.DirectorySelect("Output")
diff --git a/tox.ini b/tox.ini
index b25694836..84b4e6069 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,42 +10,35 @@ addopts = --verbose
 
 [testenv]
 deps =
-    {[testenv:pytest]deps}
+    pytest
+    pytest-mock
 
 whitelist_externals=
     python
-    {[testenv:pytest]whitelist_externals}
+    pytest
 
-commands=
-    {[testenv:pytest]commands}
+commands =  pytest --basetemp={envtmpdir} {env:pytest_args:} {posargs}
 
 [testenv:py{38,39,310}-PySide6]
 passenv =
     QT_QPA_PLATFORM
     XAUTHORITY
     DISPLAY
+    LocalAppData
 
 whitelist_externals=
     python
-    {[testenv:pytest]whitelist_externals}
+    pytest
 
 deps =
     -r requirements/requirements-gui.txt
     pytest-qt
-    {[testenv:pytest]deps}
-
-commands=
-    {[testenv:pytest]commands}
-
-; ======================== tests ========================
-[testenv:pytest]
-deps =
     pytest
     pytest-mock
-whitelist_externals = pytest
-commands =  pytest --basetemp={envtmpdir} {env:pytest_args:} {posargs}
 
+commands =  pytest --basetemp={envtmpdir} {env:pytest_args:} {posargs}
 
+; ======================== tests ========================
 [testenv:docs]
 deps=
     sphinx
@@ -80,6 +73,14 @@ skip_install=True
 commands =  pylint {posargs: speedwagon} --disable import-error
 
 
+[testenv:doctest]
+skip_install = true
+deps=
+    sphinx
+    sphinx-argparse
+    PyYAML
+    -r requirements-vendor.txt
+commands = python -m sphinx -b doctest docs/source "{envtmpdir}/build/docs" -d "{envtmpdir}/build/docs/doctrees"
 [testenv:pydocstyle]
 skip_install = true
 deps=
@@ -93,3 +94,10 @@ deps=
     bandit
 commands =
     bandit {posargs: --recursive {toxinidir}/speedwagon}
+
+[testenv:ruff]
+skip_install = true
+deps=
+    ruff
+commands =
+    ruff {posargs: {toxinidir}/speedwagon}


Test patch:
diff --git a/tests/frontend/conftest.py b/tests/frontend/conftest.py
index a787cc0a1..bb905106f 100644
--- a/tests/frontend/conftest.py
+++ b/tests/frontend/conftest.py
@@ -51,25 +51,3 @@ def __exit__(self, exc_type, exc_value, traceback):
 
     def progress_dialog_box_handler(self, *args, **kwargs):
         pass
-
-
-@pytest.fixture()
-def tool_job_manager_spy():
-    worker = pytest.importorskip("speedwagon.frontend.qtwidgets.worker")
-
-    class SpyToolJobManager(worker.ToolJobManager):
-        def open(self, parent, runner, *args, **kwargs):
-            return SpyWorkRunner(*args, **kwargs, parent=parent)
-
-        def flush_message_buffer(self):
-            pass
-
-    from speedwagon import runner_strategies
-    import speedwagon.frontend.qtwidgets
-    with SpyToolJobManager() as e:
-        manager_strat = \
-            speedwagon.frontend.qtwidgets.runners.QtRunner(parent=None)
-
-        runner = runner_strategies.RunRunner(manager_strat)
-
-        yield runner
diff --git a/tests/frontend/test_dialog.py b/tests/frontend/test_dialog.py
index 52673a445..4652de895 100644
--- a/tests/frontend/test_dialog.py
+++ b/tests/frontend/test_dialog.py
@@ -1,6 +1,8 @@
 import pytest
-from unittest.mock import Mock
+from unittest.mock import Mock, patch, mock_open
+import builtins
 QtWidgets = pytest.importorskip("PySide6.QtWidgets")
+QtCore = pytest.importorskip("PySide6.QtCore")
 
 try:
     from importlib import metadata
@@ -69,3 +71,102 @@ def mock_distributions(*args, **kwargs):
             'Eggs 0.1',
             'Spam 1.0',
         ]
+
+
+class TestSpeedwagonUnhandledExceptionDialog:
+    def test_dialog(self, qtbot):
+        dialog = dialogs.SpeedwagonExceptionDialog()
+        dialog.exception = ValueError("This is wrong")
+        assert dialog.informativeText() == "This is wrong"
+
+    def test_export_button(self, qtbot, monkeypatch):
+        dialog = dialogs.SpeedwagonExceptionDialog()
+        qtbot.addWidget(dialog)
+        dialog.exception = ValueError("This is wrong")
+        assert any(
+            button.text() == "Export Details" for button in dialog.buttons()
+        )
+
+    def test_export_report(self, qtbot, monkeypatch):
+        exec_window = Mock()
+        dialog = dialogs.SpeedwagonExceptionDialog()
+        dialog.save_report_strategy = Mock(name="save_report_strategy")
+        qtbot.addWidget(dialog)
+        monkeypatch.setattr(dialogs.QtWidgets.QMessageBox, "exec", exec_window)
+
+        qtbot.mouseClick(dialog.export_button, QtCore.Qt.LeftButton)
+        assert dialog.save_report_strategy.save.called is True
+
+    def test_exception_property(self, qtbot):
+        exception = ValueError("This is wrong")
+        dialog = dialogs.SpeedwagonExceptionDialog()
+        dialog.exception = exception
+        assert dialog.exception == exception
+
+
+class TestSaveReportDialogBox:
+    def test_save_calls_write_data(self, qtbot, monkeypatch):
+        saver = dialogs.SaveReportDialogBox()
+        write_data = Mock()
+        monkeypatch.setattr(saver, "write_data", write_data)
+        monkeypatch.setattr(
+            dialogs.QtWidgets.QFileDialog,
+            "getSaveFileName",
+            lambda *_, **__: ("bacon.txt", True)
+        )
+        saver.save("spam")
+        assert write_data.called is True
+
+    def test_save_cancels_not_calls_write_data(self, qtbot, monkeypatch):
+        saver = dialogs.SaveReportDialogBox()
+        write_data = Mock()
+        monkeypatch.setattr(saver, "write_data", write_data)
+        monkeypatch.setattr(
+            dialogs.QtWidgets.QFileDialog,
+            "getSaveFileName",
+            lambda *_, **__: (None, False)
+        )
+        saver.save("spam")
+        assert write_data.called is False
+
+    def test_save_failing_tries_again(self, qtbot, monkeypatch):
+        saver = dialogs.SaveReportDialogBox()
+
+        # ======================================================================
+        # simulate putting in file name that produces an error when writing.
+        # When the user is prompted again for a file name, they cancel it.
+
+        attempts = 0
+        def _write_data(*args, **kwargs):
+            nonlocal attempts
+            attempts += 1
+            return attempts != 1
+
+        write_data = Mock(side_effect=_write_data)
+        # ======================================================================
+        monkeypatch.setattr(saver, "write_data", write_data)
+        getSaveFileName = Mock(return_value=("bacon.txt", True))
+        monkeypatch.setattr(
+            dialogs.QtWidgets.QFileDialog,
+            "getSaveFileName",
+            getSaveFileName
+        )
+        saver.save("spam")
+        assert write_data.call_count == 2
+
+    def test_write_data(self, qtbot, monkeypatch):
+        saver = dialogs.SaveReportDialogBox()
+        with patch('builtins.open', mock_open()) as mocked_file:
+            result = saver.write_data("output.txt", "spam")
+            mocked_file().write.assert_called_once_with("spam")
+        assert result is True
+
+    def test_write_data_failed_returns_false(self, qtbot, monkeypatch):
+        saver = dialogs.SaveReportDialogBox()
+        mock_open_data = mock_open()
+        mock_open_data.side_effect = IOError("Failed")
+        message_box = Mock(name="QMessageBox")
+        monkeypatch.setattr(dialogs.QtWidgets, "QMessageBox", message_box)
+        with patch('builtins.open', mock_open_data) as mocked_file:
+            result = saver.write_data("output.txt", "spam")
+        assert result is False
diff --git a/tests/frontend/test_gui.py b/tests/frontend/test_gui.py
index a25c1e5d7..8e80fc45e 100644
--- a/tests/frontend/test_gui.py
+++ b/tests/frontend/test_gui.py
@@ -11,100 +11,8 @@
 import speedwagon.workflow
 QtWidgets = pytest.importorskip("PySide6.QtWidgets")
 QtGui = pytest.importorskip("PySide6.QtGui")
-from speedwagon.frontend.qtwidgets import shared_custom_widgets
-
-
-
-
-def test_show_help_open_web(qtbot, monkeypatch):
-    mock_work_manager = Mock()
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-
-    def mock_open_new(url, *args, **kwargs):
-        assert "http" in url
-
-    with monkeypatch.context() as e:
-        e.setattr(webbrowser, "open_new", mock_open_new)
-        main_window.show_help()
-
-
-def test_exit_button(qtbot, monkeypatch):
-    exit_calls = []
-    monkeypatch.setattr(
-        QtWidgets.QApplication,
-        "exit",
-        lambda: exit_calls.append(1)
-    )
-
-    mock_work_manager = Mock()
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-    exit_button = main_window.findChild(QtGui.QAction, name="exitAction")
-    exit_button.trigger()
-    assert exit_calls == [1]
-
-
-def test_system_info_menu(qtbot, monkeypatch):
-    from speedwagon.frontend.qtwidgets.dialog import dialogs
-    mock_work_manager = Mock()
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-    from PySide6 import QtWidgets
-    system_menu = main_window.menuBar().findChild(QtWidgets.QMenu,
-                                                  name="systemMenu")
-
-    for action in system_menu.actions():
-        if action.objectName() == "systemInfoAction":
-            system_info_action = action
-            break
-    else:
-        assert False, "systemInfoAction not found"
-    mock_exec = Mock()
-    monkeypatch.setattr(dialogs.SystemInfoDialog, "exec", mock_exec)
-    system_info_action.trigger()
-    assert mock_exec.called is True
-
-
-def test_show_configuration_menu(qtbot, monkeypatch):
-    mock_work_manager = MagicMock(settings_path="some-path")
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-    from PySide6 import QtWidgets
-    system_menu = main_window.menuBar().findChild(QtWidgets.QMenu,
-                                                  name="systemMenu")
-
-    for action in system_menu.actions():
-        if action.objectName() == "settingsAction":
-            settings_action = action
-            break
-    else:
-        assert False, "settingsAction not found"
-
-    import os
-    from speedwagon.frontend.qtwidgets.dialog import settings
-    monkeypatch.setattr(os.path, "exists", lambda x: True)
-    read_config_data = Mock()
-    monkeypatch.setattr(
-        settings.GlobalSettingsTab, "read_config_data", read_config_data
-    )
-    configure_tab_load = Mock()
-    monkeypatch.setattr(settings.TabsConfigurationTab, "load",
-                        configure_tab_load)
-
-    mock_exec = Mock()
-    monkeypatch.setattr(settings.SettingsDialog, "exec", mock_exec)
-
-    settings_action.trigger()
-    assert mock_exec.called is True
+import speedwagon.config
+from speedwagon.frontend.qtwidgets.tabs import WorkflowsTab3
 
 
 class TestToolConsole:
@@ -114,194 +22,38 @@ def test_add_message(self, qtbot):
         console.add_message("I'm a message")
         assert "I'm a message" in console.text
 
-
-def test_window_save_log(qtbot, monkeypatch):
-    mock_work_manager = MagicMock(settings_path="some-path")
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-    monkeypatch.setattr(
-        speedwagon.frontend.qtwidgets.gui.QtWidgets.QFileDialog,
-        "getSaveFileName",
-        lambda *args, **kwargs: ("spam.log", None)
-    )
-
-    m = mock_open()
-    with patch('builtins.open', m):
-        main_window.save_log()
-
-    m.assert_called_once_with('spam.log', 'w', encoding='utf-8')
-
-
-def test_set_current_tab(qtbot):
-    mock_work_manager = MagicMock(settings_path="some-path")
-    main_window = \
-        speedwagon.frontend.qtwidgets.gui.MainWindow1(mock_work_manager)
-
-    qtbot.addWidget(main_window)
-    main_window.tab_widget.tabs.count = Mock(return_value=1)
-    main_window.tab_widget.tabs.tabText = Mock(return_value='spam')
-    main_window.tab_widget.tabs.setCurrentIndex = Mock()
-
-    main_window.set_current_tab("spam")
-    assert main_window.tab_widget.tabs.setCurrentIndex.called
-
-
-class TestMainWindow:
-    def test_about_dialog_box(self, qtbot, monkeypatch):
-        work_manager = Mock()
-        work_manager.settings_path = None
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow1(work_manager)
-        qtbot.add_widget(window)
-        about_dialog_box = Mock()
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.dialog,
-            "about_dialog_box",
-            about_dialog_box
-        )
-        window.show_about_window()
-        assert about_dialog_box.called is True
-
-    def test_show_system_info(self, qtbot, monkeypatch):
-        work_manager = Mock()
-        work_manager.settings_path = None
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow1(work_manager)
-        qtbot.add_widget(window)
-        exec_ = Mock()
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.dialog.dialogs.SystemInfoDialog,
-            "exec",
-            exec_
-        )
-        window.show_system_info()
-        assert exec_.called is True
-
-    def test_show_configuration_opens_settings_dialog(
-            self,
-            qtbot,
-            monkeypatch
-    ):
-        work_manager = Mock()
-        work_manager.settings_path = None
-        window = speedwagon.frontend.qtwidgets.gui.MainWindow1(work_manager)
-        qtbot.add_widget(window)
-        exec_ = Mock()
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.dialog.settings.SettingsDialog,
-            "exec",
-            exec_
-        )
-        window.show_configuration()
-        assert exec_.called is True
-
-
-class TestMainWindow2:
-    def test_exit(self, qtbot, monkeypatch):
-        exit_called = Mock()
-        manager = Mock()
-
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui.QtWidgets.QWidget,
-            "close",
-            exit_called
-        )
-
-        main_window = speedwagon.frontend.qtwidgets.gui.MainWindow2(manager)
-        qtbot.addWidget(main_window)
-        main_window.findChild(QtGui.QAction, name="exitAction").trigger()
-        assert exit_called.called is True
-
-    @pytest.mark.parametrize("tab_name", ["Spam", "Dummy"])
-    def test_set_current_tab(self, qtbot, tab_name):
-        manager = Mock()
-        main_window = speedwagon.frontend.qtwidgets.gui.MainWindow2(manager)
-        workflows_tab1 = speedwagon.frontend.qtwidgets.tabs.WorkflowsTab2(
-            parent=main_window.tab_widget,
-            workflows=MagicMock(),
-        )
-        main_window.tab_widget.add_tab(workflows_tab1.tab_widget, "Dummy")
-        workflows_tab2 = speedwagon.frontend.qtwidgets.tabs.WorkflowsTab2(
-            parent=main_window.tab_widget,
-            workflows=MagicMock(),
-        )
-        main_window.tab_widget.add_tab(workflows_tab2.tab_widget, "Spam")
-
-        main_window.set_current_tab(tab_name)
-        current_tab_name = \
-            main_window.tab_widget.tabs.tabText(
-                main_window.tab_widget.tabs.currentIndex()
+class TestMainWindow3:
+    def test_updated_settings_uses_config_strategy(self, qtbot):
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        window.config_strategy = Mock(speedwagon.config.AbsConfigSettings)
+        window.update_settings()
+        assert window.config_strategy.settings.called is True
+
+    def test_debug_mode_reflected_in_window_title(self, qtbot):
+        class DummyConfigSettings(speedwagon.config.AbsConfigSettings):
+            def settings(self):
+                return {"GLOBAL": {"debug": True}}
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        window.config_strategy = DummyConfigSettings()
+        window.update_settings()
+        assert "DEBUG" in window.windowTitle()
+#
+    def test_normal_mode_dose_not_have_debug_in_window_title_text(self, qtbot):
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        window.config_strategy = \
+            Mock(
+                speedwagon.config.AbsConfigSettings,
+                settings=Mock(return_value={"debug": False})
             )
-        assert current_tab_name == tab_name
-
-    def test_set_current_tab_invalid_throws(self, qtbot):
-        main_window = speedwagon.frontend.qtwidgets.gui.MainWindow2(Mock())
-        main_window.tab_widget.add_tab(
-            speedwagon.frontend.qtwidgets.tabs.WorkflowsTab2(
-                parent=main_window.tab_widget,
-                workflows=MagicMock(),
-            ).tab_widget,
-            "Spam"
-        )
-
-        # eggs is NOT a valid tab
-        with pytest.raises(IndexError):
-            main_window.set_current_tab("eggs")
-
-    def test_set_active_workflow(self, qtbot):
-        main_window = speedwagon.frontend.qtwidgets.gui.MainWindow2(Mock())
-
-        bacon = MagicMock()
-        bacon.name = "Bacon"
-
-        eggs = MagicMock()
-        eggs.name = "Eggs"
-
-        main_window.add_tab(
-            "All", {
-                "Bacon": bacon,
-                "Eggs": eggs,
-            }
-        )
-        main_window.tab_widget.add_tab(
-            speedwagon.frontend.qtwidgets.tabs.WorkflowsTab2(
-                parent=main_window.tab_widget,
-                workflows=MagicMock(),
-            ).tab_widget,
-            "Spam"
-        )
-        main_window.set_active_workflow("Eggs")
-        assert main_window.get_current_workflow_name() == "Eggs"
-
-    def test_set_current_workflow_settings(self, qtbot):
-        main_window = speedwagon.frontend.qtwidgets.gui.MainWindow2(Mock())
-
-        class Eggs(speedwagon.Workflow):
-            def discover_task_metadata(self, initial_results: List[Any],
-                                       additional_data: Dict[str, Any],
-                                       **user_args) -> List[dict]:
-                return []
-
-            def get_user_options(self) -> List[
-                speedwagon.workflow.AbsOutputOptionDataType
-            ]:
-                return [
-                    speedwagon.workflow.TextLineEditData("Dummy")
-                ]
-
-            def user_options(self) -> typing.List[Any]:
-                return [
-                    shared_custom_widgets.UserOptionPythonDataType2(
-                        label_text="Dummy"
-                    )
-                ]
-
-        main_window.add_tab("All", {"Eggs": Eggs})
-
-        main_window.set_active_workflow("Eggs")
-
-        main_window.set_current_workflow_settings({"Dummy": "Yes"})
-        assert main_window.get_current_job_settings()["Dummy"] == "Yes"
+        window.update_settings()
+        assert "DEBUG" not in window.windowTitle()
+
+    def test_export_job_config_triggered_by_action_export_job(self, qtbot):
+        window = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        tab = WorkflowsTab3()
+        window.tab_widget.add_tab(tab, "dummy")
+        with qtbot.waitSignal(window.export_job_config):
+            window.action_export_job.trigger()
 
 
 def test_load_job_settings_model(qtbot):
@@ -374,4 +126,55 @@ def test_load_items_with_choice(qtbot):
         'French',
         'German',
         'Spanish'
-    ]
\ No newline at end of file
+    ]
+
+
+class TestItemTabsWidget:
+    def test_add_tab(self, qtbot):
+
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        dummy = QtWidgets.QWidget()
+        assert tabs_widget.count() == 0
+        tabs_widget.add_tab(dummy, "hello")
+
+        text = [tabs_widget.tabs.tabText(r) for r in range(tabs_widget.count())]
+        assert tabs_widget.count() == 1, f"Tabs found: {*text, }"
+
+    def test_add_tab_to_model_adds_to_widget(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        model = tabs_widget.model()
+        assert tabs_widget.count() == 0
+        model.append_workflow_tab("Spam", [])
+        assert tabs_widget.count() == 1
+
+    def test_add_tab_widget(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        tabs_widget.add_workflows_tab("hello", [])
+        assert tabs_widget.count() == 1
+
+    def test_add_tab_widget_and_clear(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        tabs_widget.add_workflows_tab("hello", [])
+        tabs_widget.clear_tabs()
+        names = [tabs_widget.model().data(tabs_widget.model().index(row_id)) for row_id in range(tabs_widget.model().rowCount())]
+        assert tabs_widget.count() == 0, f"Found tabs {*names, }"
+
+    def test_append_workflow_to_model_and_clear_from_widget(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        model = tabs_widget.model()
+        model.append_workflow_tab("Spam", [])
+        assert tabs_widget.count() == 1
+        tabs_widget.clear_tabs()
+        assert tabs_widget.count() == 0
+
+    def test_clear(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        dummy = QtWidgets.QWidget()
+        tabs_widget.add_tab(dummy, "hello")
+        assert tabs_widget.count() == 1
+        tabs_widget.clear_tabs()
+        assert tabs_widget.count() == 0
+
+    def test_empty_current_tab_is_none(self, qtbot):
+        tabs_widget = speedwagon.frontend.qtwidgets.gui.ItemTabsWidget()
+        assert tabs_widget.current_tab is None
diff --git a/tests/frontend/test_gui_startup.py b/tests/frontend/test_gui_startup.py
index 11390197c..db48b175c 100644
--- a/tests/frontend/test_gui_startup.py
+++ b/tests/frontend/test_gui_startup.py
@@ -4,14 +4,18 @@
 import os
 
 import pytest
-from unittest.mock import Mock, MagicMock, patch, mock_open, ANY
+from unittest.mock import Mock, MagicMock, patch, mock_open, ANY, call
 import io
+
 import speedwagon.config
 gui_startup = pytest.importorskip("speedwagon.frontend.qtwidgets.gui_startup")
-from PySide6 import QtWidgets
-from speedwagon.frontend.qtwidgets.dialog import dialogs
-from speedwagon.frontend.qtwidgets.dialog.settings import SettingsDialog
 
+from PySide6 import QtWidgets, QtCore, QtGui
+from speedwagon.frontend.qtwidgets.dialog import dialogs
+from speedwagon.frontend.qtwidgets.dialog.settings import SettingsDialog, TabEditor
+from speedwagon.frontend.qtwidgets.models.tabs import AbsLoadTabDataModelStrategy
+from speedwagon.frontend.qtwidgets.gui_startup import save_workflow_config, TabsEditorApp
+from speedwagon.frontend.qtwidgets.models import tabs as tab_models
 
 def test_standalone_tab_editor_loads(qtbot, monkeypatch):
     TabsEditorApp = MagicMock()
@@ -33,367 +37,85 @@ def test_standalone_tab_editor_loads(qtbot, monkeypatch):
         get_platform_settings
     )
 
-    gui_startup.standalone_tab_editor(app)
+    with pytest.raises(SystemExit):
+        gui_startup.standalone_tab_editor(app)
     assert app.exec.called is True
 
 
 class TestTabsEditorApp:
     def test_on_okay_closes(self, qtbot):
-        editor = gui_startup.TabsEditorApp()
+        editor = TabsEditorApp()
         qtbot.addWidget(editor)
         editor.close = Mock()
         editor.on_okay()
         assert editor.close.called is True
 
-#
-# def test_run_loads_window(qtbot, monkeypatch, tmpdir):
-#     app = Mock()
-#     app.exec_ = MagicMock()
-#
-#     def dummy_app_data_dir(*args, **kwargs):
-#         app_data_dir = tmpdir / "app_data_dir"
-#         app_data_dir.ensure_dir()
-#         return app_data_dir.strpath
-#
-#     monkeypatch.setattr(
-#         speedwagon.config.get_platform_settings().__class__,
-#         "get_app_data_directory",
-#         dummy_app_data_dir
-#     )
-#
-#     monkeypatch.setattr(
-#         speedwagon.config.NixConfig,
-#         "get_user_data_directory",
-#         lambda _: '~'
-#     )
-#
-#     standard_startup = gui_startup.StartupGuiDefault(app=app)
-#
-#     standard_startup.startup_settings['debug'] = True
-#     tabs_file = tmpdir / "tabs.yaml"
-#     tabs_file.ensure()
-#
-#     # get_app_data_directory
-#
-#     standard_startup.tabs_file = tabs_file
-#
-#     monkeypatch.setattr(QtWidgets, "QSplashScreen", MagicMock())
-#     monkeypatch.setattr(
-#         speedwagon.frontend.qtwidgets.gui,
-#         "MainWindow1",
-#         MagicMock()
-#     )
-#     standard_startup._logger = Mock()
-#     standard_startup.run()
-#     assert app.exec_.called is True
-#
-
-# class TestStartupDefault:
-#     @pytest.fixture
-#     def parse_args(self):
-#         def parse(*args, **kwargs):
-#             return argparse.Namespace(
-#                 debug=False,
-#                 start_tab="main"
-#             )
-#         return parse
-#
-#     # def test_invalid_setting_logs_warning(
-#     #         self,
-#     #         caplog,
-#     #         monkeypatch,
-#     #         parse_args
-#     # ):
-#     #
-#     #     def update(*_, **__):
-#     #         raise ValueError("oops")
-#     #     # Monkey patch Path.home() because this will fail on linux systems if
-#     #     # uid not found. For example: in some docker containers
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.Path, "home", lambda: "my_home"
-#     #     )
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.startup.argparse.ArgumentParser,
-#     #         "parse_args",
-#     #         parse_args
-#     #     )
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.WindowsConfig,
-#     #         "get_app_data_directory",
-#     #         lambda *_: "app_data_dir"
-#     #     )
-#     #     startup_worker = gui_startup.StartupGuiDefault(app=Mock())
-#     #     resolution = Mock(FRIENDLY_NAME="dummy")
-#     #     resolution.update = lambda _: update()
-#     #
-#     #     loader = speedwagon.config.ConfigLoader(startup_worker.config_file)
-#     #     loader.resolution_strategy_order = [resolution]
-#     #
-#     #     startup_worker.resolve_settings(
-#     #         resolution_order=[resolution],
-#     #         loader=loader
-#     #     )
-#     #
-#     #     assert any("oops is an invalid setting" in m for m in caplog.messages)
-#
-#     # def test_invalid_setting_logs_warning_for_ConfigFileSetter(
-#     #         self, caplog, monkeypatch, parse_args):
-#     #
-#     #     def update(*_, **__):
-#     #         raise ValueError("oops")
-#     #
-#     #     # Monkey patch Path.home() because this will fail on linux systems if
-#     #     # uid not found. For example: in some docker containers
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.Path, "home", lambda: "my_home"
-#     #     )
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.WindowsConfig,
-#     #         "get_app_data_directory",
-#     #         lambda *_: "app_data_dir"
-#     #     )
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.startup.argparse.ArgumentParser,
-#     #         "parse_args",
-#     #         parse_args
-#     #     )
-#     #
-#     #     startup_worker = gui_startup.StartupGuiDefault(app=Mock())
-#     #     resolution = Mock(FRIENDLY_NAME="dummy")
-#     #     resolution.__class__ = speedwagon.config.ConfigFileSetter
-#     #     resolution.update = lambda _: update()
-#     #     startup_worker.resolve_settings(resolution_order=[resolution])
-#     #     assert any("contains an invalid setting" in m for m in caplog.messages)
-#
-#     # def test_missing_debug_setting(self, caplog, monkeypatch, parse_args):
-#     #     # Monkey patch Path.home() because this will fail on linux systems if
-#     #     # uid not found. For example: in some docker containers
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.Path, "home", lambda: "my_home"
-#     #     )
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.WindowsConfig,
-#     #         "get_app_data_directory",
-#     #         lambda *_: "app_data_dir"
-#     #     )
-#     #
-#     #     startup_worker = gui_startup.StartupGuiDefault(app=Mock())
-#     #     startup_worker.startup_settings = {"sss": "dd"}
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.startup.argparse.ArgumentParser,
-#     #         "parse_args",
-#     #         parse_args
-#     #     )
-#     #
-#     #     loader = speedwagon.config.ConfigLoader(startup_worker.config_file)
-#     #     loader.resolution_strategy_order = []
-#     #     loader.startup_settings = {"sss": "dd"}
-#     #
-#     #     startup_worker.resolve_settings(
-#     #         resolution_order=[],
-#     #         loader=loader
-#     #     )
-#     #
-#     #     assert any(
-#     #         "Unable to find a key for debug mode" in m for m in caplog.messages
-#     #     )
-#
-#     # def test_default_resolve_settings_calls_default_setter(self, monkeypatch):
-#     #
-#     #     def update(*_, **__):
-#     #         raise ValueError("oops")
-#     #
-#     #     default_setter = MagicMock()
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config, "DefaultsSetter", default_setter
-#     #     )
-#     #
-#     #     # Monkey patch Path.home() because this will fail on linux systems if
-#     #     # uid not found. For example: in some docker containers
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.Path, "home", lambda: "my_home"
-#     #     )
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.WindowsConfig,
-#     #         "get_app_data_directory",
-#     #         lambda *_: "app_data_dir"
-#     #     )
-#     #
-#     #     monkeypatch.setattr(
-#     #         speedwagon.config.CliArgsSetter, "update", MagicMock()
-#     #     )
-#     #
-#     #     startup_worker = \
-#     #         gui_startup.StartupGuiDefault(app=Mock(name="app"))
-#     #
-#     #     resolution = Mock(friendly_name="dummy")
-#     #     resolution.update = lambda _: update()
-#     #     startup_worker.resolve_settings()
-#     #     assert default_setter.called is True
-#
-#     def test_ensure_settings_files_called_generate_default(
-#             self,
-#             monkeypatch,
-#             first_time_startup_worker
-#     ):
-#         generate_default = Mock()
-#
-#         monkeypatch.setattr(
-#             speedwagon.startup.speedwagon.config,
-#             "generate_default",
-#             generate_default
-#         )
-#
-#         first_time_startup_worker.ensure_settings_files()
-#         assert generate_default.called is True
-#
-#     @pytest.fixture()
-#     def first_time_startup_worker(self, monkeypatch):
-#         # Monkey patch Path.home() because this will fail on linux systems if
-#         # uid not found. For example: in some docker containers
-#         monkeypatch.setattr(
-#             speedwagon.config.Path, "home", lambda: "my_home"
-#         )
-#         monkeypatch.setattr(
-#             speedwagon.config.WindowsConfig,
-#             "get_app_data_directory",
-#             lambda *_: "app_data_dir"
-#         )
-#         startup_worker = gui_startup.StartupGuiDefault(app=Mock())
-#         startup_worker.config_file = "dummy.yml"
-#         startup_worker.tabs_file = "tabs.yml"
-#         startup_worker.app_data_dir = \
-#             os.path.join("invalid", "app_data", "path")
-#
-#         startup_worker.user_data_dir = os.path.join("invalid", "path")
-#
-#         def exists(path):
-#             config_files = [
-#                 startup_worker.config_file,
-#                 startup_worker.tabs_file,
-#                 startup_worker.app_data_dir,
-#                 startup_worker.user_data_dir
-#             ]
-#             if path in config_files:
-#                 return False
-#
-#             return False
-#
-#         monkeypatch.setattr(
-#             speedwagon.startup.os.path, "exists", exists
-#         )
-#
-#         makedirs = Mock()
-#         monkeypatch.setattr(speedwagon.startup.os, "makedirs", makedirs)
-#
-#         touch = Mock()
-#         monkeypatch.setattr(speedwagon.config.pathlib.Path, "touch", touch)
-#
-#         return startup_worker
-#
-#     @pytest.fixture()
-#     def returning_startup_worker(self, monkeypatch):
-#         # Monkey patch Path.home() because this will fail on linux systems if
-#         # uid not found. For example: in some docker containers
-#         monkeypatch.setattr(
-#             speedwagon.config.Path, "home", lambda: "my_home"
-#         )
-#         monkeypatch.setattr(
-#             speedwagon.config.WindowsConfig,
-#             "get_app_data_directory",
-#             lambda *_: "app_data_dir"
-#         )
-#         startup_worker = gui_startup.StartupGuiDefault(app=Mock())
-#         startup_worker.config_file = "dummy.yml"
-#         startup_worker.tabs_file = "tabs.yml"
-#         startup_worker.app_data_dir = os.path.join("some", "path")
-#         startup_worker.user_data_dir = os.path.join("some", "user", "path")
-#
-#         def exists(path):
-#             config_files = [
-#                 startup_worker.config_file,
-#                 startup_worker.tabs_file,
-#                 startup_worker.app_data_dir,
-#                 startup_worker.user_data_dir
-#             ]
-#             if path in config_files:
-#                 return True
-#             return False
-#
-#         monkeypatch.setattr(
-#             speedwagon.startup.os.path, "exists", exists
-#         )
-#         makedirs = Mock()
-#         monkeypatch.setattr(speedwagon.config.os, "makedirs", makedirs)
-#
-#         touch = Mock()
-#         monkeypatch.setattr(speedwagon.config.pathlib.Path, "touch", touch)
-#
-#         return startup_worker
-#
-#     @pytest.mark.parametrize(
-#         "expected_message",
-#         [
-#             'No config file found',
-#             "No tabs.yml file found",
-#             "Created",
-#             "Created directory "
-#         ]
-#     )
-#     def test_ensure_settings_files_called_messages(
-#             self,
-#             monkeypatch,
-#             caplog,
-#             expected_message,
-#             first_time_startup_worker
-#     ):
-#         generate_default = Mock()
-#
-#         monkeypatch.setattr(
-#             speedwagon.startup.speedwagon.config,
-#             "generate_default",
-#             generate_default
-#         )
-#
-#         first_time_startup_worker.ensure_settings_files()
-#
-#         assert any(
-#             expected_message in m for m in caplog.messages
-#         )
-#
-#     @pytest.mark.parametrize(
-#         "expected_message",
-#         [
-#             'Found existing config file',
-#             "Found existing tabs file",
-#             "Found existing app data",
-#             "Found existing user data directory"
-#         ]
-#     )
-#     def test_ensure_settings_files_called_messages_on_success(
-#             self,
-#             monkeypatch,
-#             caplog,
-#             expected_message,
-#             returning_startup_worker
-#     ):
-#         generate_default = Mock()
-#
-#         monkeypatch.setattr(
-#             speedwagon.startup.speedwagon.config,
-#             "generate_default",
-#             generate_default
-#         )
-#
-#         returning_startup_worker.ensure_settings_files()
-#         assert any(
-#             expected_message in m for m in caplog.messages
-#         )
+    def test_save_on_modify(self, qtbot, monkeypatch):
+        app = TabsEditorApp()
+        qtbot.addWidget(app)
+        editor: TabEditor = app.editor
+
+        class DummyLoader(AbsLoadTabDataModelStrategy):
+            def load(self, model) -> None:
+                class Spam1(speedwagon.Workflow):
+                    name = "spam 1"
+
+                class Spam2(speedwagon.Workflow):
+                    name = "spam 2"
+
+                class Spam3(speedwagon.Workflow):
+                    name = "spam 3"
+
+                model.append_workflow_tab("All", [Spam1, Spam2, Spam3])
+                model.append_workflow_tab("Eggs", [Spam1])
+        # editor.set_tab_visibility("Eggs", True)
+        editor.load_tab_data_model_strategy = DummyLoader()
+        editor.load_data()
+        mo = editor.all_workflows_list_view.model()
+
+        qtbot.mouseClick(
+            editor.all_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.all_workflows_list_view.visualRect(
+                mo.index(1,0)
+            ).center()
+        )
+
+        assert editor.model.rowCount(editor.model.index(1)) == 1
+        with qtbot.waitSignal(editor.model.dataChanged):
+            qtbot.mouseClick(
+                editor.add_items_button,
+                QtCore.Qt.LeftButton,
+            )
+
+        def get_error_info():
+            existing_tabs = [
+                editor.model.data(
+                    editor.model.index(i, parent=editor.model.index(1))
+                )
+                for i in range(editor.model.rowCount(editor.model.index(1)))
+            ]
+            return f"Got the following workflows loaded: {*existing_tabs,}"
+
+        assert \
+            editor.model.rowCount(editor.model.index(1)) == 2, \
+            get_error_info()
+        save = Mock()
+        monkeypatch.setattr(app, "get_tab_config_strategy", Mock(return_value=Mock(save=save)))
+        okay_button = app.dialog_button_box.button(QtWidgets.QDialogButtonBox.StandardButton.Ok)
+        qtbot.mouseClick(
+            okay_button,
+            QtCore.Qt.LeftButton,
+        )
+        save.assert_called_with(
+            [
+                speedwagon.config.CustomTabData(
+                    tab_name='Eggs',
+                    workflow_names=["spam 1", "spam 2"]
+                )
+            ]
+        )
+
 
 
 class TestSingleWorkflowJSON:
@@ -414,6 +136,11 @@ def test_run_without_options_raises_exception(self):
         assert "no data" in str(error.value).lower()
 
     def test_runner_strategies_called(self, monkeypatch, qtbot):
+        monkeypatch.setattr(
+            speedwagon.config,
+            "get_whitelisted_plugins",
+            lambda: []
+        )
         import tracemalloc
         tracemalloc.start()
         monkeypatch.setattr(
@@ -437,7 +164,7 @@ def test_runner_strategies_called(self, monkeypatch, qtbot):
 
         monkeypatch.setattr(
             speedwagon.frontend.qtwidgets.gui,
-            "MainWindow2",
+            "MainWindow3",
             Mock()
         )
 
@@ -459,34 +186,6 @@ def test_runner_strategies_called(self, monkeypatch, qtbot):
         startup.run()
         assert submit_job.called is True
 
-    def test_signal_is_sent(self, qtbot):
-        from PySide6 import QtCore
-
-        class Dummy(QtCore.QObject):
-            dummy_signal = QtCore.Signal(str, int)
-
-            def __init__(self):
-                super().__init__()
-                self.dummy_signal.connect(self.d)
-
-            def d(self, message, level):
-                print("hhh")
-
-        dummy = Dummy()
-
-        signal_log_handler = \
-            speedwagon.frontend.qtwidgets.logging_helpers.SignalLogHandler(
-                dummy.dummy_signal
-            )
-
-        logger = logging.getLogger()
-        logger.setLevel(logging.INFO)
-        logger.addHandler(signal_log_handler)
-
-        with qtbot.waitSignal(dummy.dummy_signal) as f:
-            logger.info("Spam!")
-        logger.removeHandler(signal_log_handler)
-
     def test_run_on_exit_is_called(self, qtbot, monkeypatch):
         startup = \
             speedwagon.frontend.qtwidgets.gui_startup.SingleWorkflowJSON(
@@ -499,11 +198,11 @@ def test_run_on_exit_is_called(self, qtbot, monkeypatch):
         startup.workflow = workflow
         startup.on_exit = Mock()
 
-        MainWindow2 = QtWidgets.QMainWindow()
-        MainWindow2.logger = Mock()
-        MainWindow2.console = Mock()
-        MainWindow2.show = Mock()
-        qtbot.addWidget(MainWindow2)
+        MainWindow3 = QtWidgets.QMainWindow()
+        MainWindow3.logger = Mock()
+        MainWindow3.console = Mock()
+        MainWindow3.show = Mock()
+        qtbot.addWidget(MainWindow3)
         monkeypatch.setattr(
             dialogs.WorkflowProgress,
             "exec",
@@ -516,8 +215,8 @@ def test_run_on_exit_is_called(self, qtbot, monkeypatch):
         )
         monkeypatch.setattr(
             speedwagon.frontend.qtwidgets.gui,
-            "MainWindow2",
-            lambda _: MainWindow2
+            "MainWindow3",
+            lambda _: MainWindow3
         )
 
         monkeypatch.setattr(
@@ -529,7 +228,12 @@ def test_run_on_exit_is_called(self, qtbot, monkeypatch):
         startup.run()
         assert startup.on_exit.called is True
 
-    def test_load_json(self):
+    def test_load_json(self, monkeypatch):
+        monkeypatch.setattr(
+            speedwagon.config,
+            "get_whitelisted_plugins",
+            lambda: []
+        )
         startup = gui_startup.SingleWorkflowJSON(app=None)
 
         startup.load_json_string(
@@ -549,63 +253,6 @@ def test_load_json(self):
                startup.workflow.name == 'Zip Packages'
 
 
-class TestMultiWorkflowLauncher:
-    def test_all_workflows_validate_user_options(self, qtbot, monkeypatch):
-        startup_launcher = gui_startup.MultiWorkflowLauncher(app=None)
-        workflow_tasks = [
-
-            (
-                'Verify Checksum Batch [Single]',
-                {
-                    "Input": os.path.join("somepath", "checksum.md5")
-                }
-
-            ),
-            (
-                "Convert CaptureOne TIFF to Hathi TIFF package",
-                {
-                    "Input": os.path.join("some", "valid", "input", "path"),
-                    "Output": os.path.join("some", "valid", "output", "path")
-                }
-            ),
-        ]
-
-        jobs = []
-
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui.MainWindow1,
-            "show", lambda self: None
-        )
-
-        for workflow_name, workflow_args in workflow_tasks:
-            mock_workflow = MagicMock()
-            mock_workflow.name = workflow_name
-            mock_workflow.__class__ = speedwagon.job.Workflow
-            jobs.append(mock_workflow)
-            startup_launcher.add_job(mock_workflow, workflow_args)
-        startup_launcher.run()
-        assert all(job.validate_user_options.called is True for job in jobs)
-
-    def test_task_failing(self, qtbot):
-        startup_launcher = gui_startup.MultiWorkflowLauncher(app=None)
-        mock_workflow = MagicMock()
-        mock_workflow.name = 'Verify Checksum Batch [Single]'
-        mock_workflow.__class__ = speedwagon.job.Workflow
-        mock_workflow.initial_task = \
-            Mock(
-                side_effect=speedwagon.frontend.qtwidgets.runners.TaskFailed()
-            )
-
-        startup_launcher.add_job(
-            mock_workflow,
-            {
-                    "Input": os.path.join("somepath", "checksum.md5")
-                }
-         )
-        with pytest.raises(speedwagon.exceptions.JobCancelled):
-            startup_launcher.run()
-
-
 class TestStartQtThreaded:
     @pytest.fixture(scope="function")
     def starter(self, monkeypatch, qtbot):
@@ -616,41 +263,43 @@ def starter(self, monkeypatch, qtbot):
         )
 
         monkeypatch.setattr(
-            speedwagon.config.Path,
+            speedwagon.config.pathlib.Path,
             "home",
             lambda *_: "/usr/home"
         )
 
         app = Mock()
         startup = gui_startup.StartQtThreaded(app)
+        class SettingsFileLocatorDummy(speedwagon.config.AbsSettingLocator):
+            def get_app_data_dir(self):
+                return ""
+
+            def get_config_file(self):
+                return ""
+            def get_tabs_file(self):
+                return ""
+            def get_user_data_dir(self):
+                return ""
+        # startup.config_loader_strategy = SettingsFileLocatorDummy()
+        def read_settings_file_plugins(*args, **kwargs):
+            return {}
+        # monkeypatch.setattr(
+        #     speedwagon.config,
+        #     "ConfigLoader",
+        #     Mock(name="ConfigLoader", read_settings_file_plugins=read_settings_file_plugins)
+        # )
         yield startup
         if startup.windows is not None:
             startup.windows.close()
         startup.app.closeAllWindows()
 
-    def test_report_exception(self, qtbot, monkeypatch, starter):
-        from PySide6 import QtWidgets
-        message_box = Mock(name="QMessageBox")
-
-        monkeypatch.setattr(
-            QtWidgets,
-            "QMessageBox",
-            Mock(return_value=message_box)
-        )
-
-        error_message = "I'm an error"
-
-        exc = ValueError(error_message)
-        starter.report_exception(exc)
-        message_box.setText.assert_called_with(error_message)
-
     def test_save_workflow_config(self, qtbot, starter):
         dialog = Mock()
         parent = QtWidgets.QWidget()
         dialog.getSaveFileName = MagicMock(return_value=("make_jp2.json", ""))
 
         serialization_strategy = Mock()
-        starter.save_workflow_config(
+        save_workflow_config(
             workflow_name="Spam",
             data={},
             parent=parent,
@@ -672,6 +321,26 @@ def test_load_workflow_config(self, qtbot, starter):
         )
         assert serialization_strategy.load.called is True
 
+    def test_load_workflow_config_cancel(self, qtbot, starter):
+        dialog = Mock()
+        dialog.getOpenFileName = MagicMock(return_value=("", ""))
+
+        serialization_strategy = MagicMock()
+        serialization_strategy.load = Mock(return_value=("name", {}))
+        starter.import_workflow_config(
+            parent=Mock(),
+            dialog_box=dialog,
+            serialization_strategy=serialization_strategy
+        )
+        assert serialization_strategy.load.called is False
+
+    def test_load_workflows_no_window(self, starter, monkeypatch):
+        load_custom_tabs = Mock()
+        starter.windows = None
+        monkeypatch.setattr(starter, "load_custom_tabs", load_custom_tabs)
+        starter.load_workflows()
+        assert load_custom_tabs.called is False
+
     def test_save_log_opens_dialog(self, qtbot, monkeypatch, starter):
         from PySide6 import QtWidgets
         getSaveFileName = Mock(
@@ -746,8 +415,7 @@ def test_request_system_info(self, monkeypatch, qtbot):
 
     def test_request_settings_opens_setting_dialog(self, qtbot, monkeypatch):
         exec_ = Mock()
-        monkeypatch.setattr(SettingsDialog, "exec_", exec_)
-
+        monkeypatch.setattr(SettingsDialog, "exec", exec_)
         monkeypatch.setattr(
             speedwagon.frontend.qtwidgets.dialog.settings.GlobalSettingsTab,
             "read_config_data",
@@ -759,9 +427,18 @@ def test_request_settings_opens_setting_dialog(self, qtbot, monkeypatch):
             "load",
             Mock()
         )
+        def load(_, model):
+            data = {"All": []}
+            for tab_name, workflows in data.items():
+                model.append_workflow_tab(tab_name, workflows)
+        monkeypatch.setattr(
+            tab_models.TabDataModelConfigLoader,
+            "load",
+            load
+        )
 
         monkeypatch.setattr(
-            speedwagon.config.Path,
+            speedwagon.config.pathlib.Path,
             "home",
             lambda *_: "/usr/home"
         )
@@ -772,28 +449,33 @@ def test_request_settings_opens_setting_dialog(self, qtbot, monkeypatch):
             lambda *_: "app_data_dir"
         )
 
-        gui_startup.StartQtThreaded.request_settings()
+        start = gui_startup.StartQtThreaded(Mock())
+        start.request_settings()
         assert exec_.called is True
 
     def test_run_opens_window(self, qtbot, monkeypatch, starter):
 
-        main_window2 = Mock(spec=speedwagon.frontend.qtwidgets.gui.MainWindow2)
-        main_window2.show = Mock()
-        main_window2.console = Mock()
-        MainWindow2 = Mock(
-            name="MainWindow2",
-            return_value=main_window2,
+        main_window3 = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        main_window3.show = Mock()
+        main_window3.config_strategy = Mock()
+        # main_window3.console = Mock()
+        MainWindow3 = Mock(
+            name="MainWindow3",
+            return_value=main_window3,
         )
         monkeypatch.setattr(
             speedwagon.frontend.qtwidgets.gui,
-            "MainWindow2",
-            MainWindow2
+            "MainWindow3",
+            MainWindow3
+        )
+        monkeypatch.setattr(
+            speedwagon.config.ConfigFileSetter,
+            "update", Mock()
         )
-
         starter.load_custom_tabs = Mock()
         starter.load_all_workflows_tab = Mock()
         starter.run()
-        assert main_window2.show.called is True
+        assert main_window3.show.called is True
 
     def test_load_custom_tabs(self, qtbot, monkeypatch, starter):
         tabs_file = "somefile.yml"
@@ -801,7 +483,7 @@ def test_load_custom_tabs(self, qtbot, monkeypatch, starter):
         loaded_workflows = Mock()
 
         monkeypatch.setattr(
-            speedwagon.startup.os.path,
+            os.path,
             "getsize",
             Mock(return_value=10)
         )
@@ -830,16 +512,23 @@ def test_load_help_no_package_info(
             caplog,
             starter
     ):
-        show = Mock()
-
+        main_window3 = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        main_window3.show = Mock()
+        main_window3.config_strategy = Mock()
+        # main_window3.console = Mock()
+        MainWindow3 = Mock(
+            name="MainWindow3",
+            return_value=main_window3,
+        )
         monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui.MainWindow2,
-            "show",
-            show
+            speedwagon.frontend.qtwidgets.gui,
+            "MainWindow3",
+            MainWindow3
         )
 
         starter.load_custom_tabs = Mock()
         starter.load_all_workflows_tab = Mock()
+
         starter.run()
 
         monkeypatch.setattr(
@@ -851,16 +540,21 @@ def test_load_help_no_package_info(
                 )
             )
         )
-        starter.windows.help_requested.emit()
+        starter.windows.action_help_requested.triggered.emit()
         assert any("No help link available" in m for m in caplog.messages)
 
     def test_load_help(self, qtbot, monkeypatch, starter):
-        show = Mock()
-
+        main_window3 = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        main_window3.show = Mock()
+        main_window3.config_strategy = Mock()
+        MainWindow3 = Mock(
+            name="MainWindow3",
+            return_value=main_window3,
+        )
         monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui.MainWindow2,
-            "show",
-            show
+            speedwagon.frontend.qtwidgets.gui,
+            "MainWindow3",
+            MainWindow3
         )
 
         starter.load_custom_tabs = Mock()
@@ -884,7 +578,7 @@ def metadata(*args, **kwargs):
         )
 
         qtbot.addWidget(starter.windows)
-        starter.windows.help_requested.emit()
+        starter.windows.action_help_requested.triggered.emit()
         assert open_new.called is True
 
     def test_resolve_settings_calls_get_settings(
@@ -895,33 +589,41 @@ def test_resolve_settings_calls_get_settings(
     ):
         starter.load_custom_tabs = Mock()
         starter.load_all_workflows_tab = Mock()
+
+        main_window3 = speedwagon.frontend.qtwidgets.gui.MainWindow3()
+        main_window3.show = Mock()
+        main_window3.config_strategy = Mock()
+        MainWindow3 = Mock(
+            name="MainWindow3",
+            return_value=main_window3,
+        )
         monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui.MainWindow2,
-            "show",
-            lambda *args, **kwargs: None
+            speedwagon.frontend.qtwidgets.gui,
+            "MainWindow3",
+            MainWindow3
         )
-
-        starter.run()
-        loader = Mock()
-        loader.get_settings = Mock(return_value={})
-        loader.read_settings_file = Mock(return_value={})
-        starter.resolve_settings(resolution_order=[], loader=loader)
-        try:
-            assert loader.get_settings.called is True
-        finally:
-            starter.windows.console.detach_logger()
-
-    def test_read_settings_file(self, qtbot, monkeypatch, starter):
-        read = Mock()
-
         monkeypatch.setattr(
-            speedwagon.config.configparser.ConfigParser,
-            "read",
-            read
+            starter.settings_resolver,
+            "get_settings",
+            Mock(
+                name="get_settings",
+                return_value={}
+            )
         )
-
-        starter.read_settings_file("somefile")
-        read.assert_called_with("somefile")
+        starter.resolve_settings()
+        assert starter.settings_resolver.get_settings.called is True
+
+    # def test_read_settings_file(self, qtbot, monkeypatch, starter):
+    #     read = Mock()
+    #
+    #     monkeypatch.setattr(
+    #         speedwagon.config.configparser.ConfigParser,
+    #         "read",
+    #         read
+    #     )
+    #
+    #     starter.read_settings_file("somefile")
+    #     read.assert_called_with("somefile")
 
     def test_request_more_info_emits_request_signal(self, qtbot, starter):
         workflow = Mock()
@@ -947,7 +649,8 @@ def test_submit_job_errors_on_unknown_workflow(
         job_manager = Mock()
         workflow_name = "unknown_workflow"
         options = {}
-        starter.report_exception = Mock()
+        monkeypatch.setattr(gui_startup, "report_exception_dialog", Mock())
+        # starter.report_exception_dialog = Mock()
 
         # Simulate no valid workflow
         monkeypatch.setattr(
@@ -955,12 +658,12 @@ def test_submit_job_errors_on_unknown_workflow(
         )
 
         starter.submit_job(
-            main_app,
             job_manager,
             workflow_name,
-            options
+            options,
+            main_app,
         )
-        assert starter.report_exception.called is True
+        assert gui_startup.report_exception_dialog.called is True
 
     def test_submit_job_submits_to_job_manager(
             self,
@@ -969,7 +672,7 @@ def test_submit_job_submits_to_job_manager(
             starter
     ):
         monkeypatch.setattr(
-            speedwagon.config.Path, "home", lambda: "my_home"
+            speedwagon.config.pathlib.Path, "home", lambda: "my_home"
         )
         monkeypatch.setattr(
             speedwagon.config.WindowsConfig,
@@ -980,7 +683,6 @@ def test_submit_job_submits_to_job_manager(
         job_manager = Mock()
         workflow_name = "spam"
         options = {}
-        starter.report_exception = Mock()
         spam_workflow = Mock()
 
         monkeypatch.setattr(
@@ -1001,7 +703,43 @@ def test_submit_job_submits_to_job_manager(
         )
         assert job_manager.submit_job.called is True
 
-
+    def test_initialize(self, qtbot, monkeypatch):
+        start = gui_startup.StartQtThreaded(Mock())
+        speedwagon.config.ensure_settings_files = Mock(name="ensure_settings_files")
+        start.resolve_settings = Mock(name="resolve_settings")
+        monkeypatch.setattr(
+            speedwagon.config.WorkflowSettingsYamlExporter,
+            "write_data_to_file",
+            Mock()
+        )
+        start.initialize()
+
+        expected = {
+            "ensure_settings_files was called": True,
+            "resolve_settings was called": True,
+        }
+        actual = {
+            "ensure_settings_files was called":
+                speedwagon.config.ensure_settings_files.called,
+            "resolve_settings was called": start.resolve_settings.called,
+        }
+        assert actual == expected
+
+    def test_load_all_workflows_tab(self, qtbot):
+        start = gui_startup.StartQtThreaded(Mock())
+        main_window = Mock('MainWindow3', add_tab=Mock())
+        loaded_workflows = {}
+        start.load_all_workflows_tab(main_window, loaded_workflows)
+        main_window.add_tab.assert_called_with("All", {})
+    def test_ensure_settings_files(self, qtbot, monkeypatch):
+        start = gui_startup.StartQtThreaded(Mock())
+        monkeypatch.setattr(
+            gui_startup.config,
+            "ensure_settings_files",
+            Mock(name="ensure_settings_files")
+        )
+        start.ensure_settings_files()
+        assert gui_startup.config.ensure_settings_files.called is True
 class TestWorkflowProgressCallbacks:
 
     @pytest.fixture()
@@ -1240,6 +978,25 @@ def test_application_launcher_called(self, monkeypatch):
             run_command.run()
         assert ApplicationLauncher.called is True
 
+    def test_cli(self, monkeypatch, mocker):
+        f = io.StringIO('{"Workflow":"dummy", "Configuration": {}}')
+        args = argparse.Namespace(json=f)
+        monkeypatch.setattr(
+            speedwagon.job,
+            "available_workflows",
+            lambda: MagicMock()
+        )
+        run_command = speedwagon.startup.RunCommand(args)
+
+        run_strategy = Mock()
+        SingleWorkflowJSON = Mock()
+        monkeypatch.setattr(run_command, "get_gui_strategy", Mock(side_effect=ImportError))
+        monkeypatch.setattr(run_command, "_run_strategy", run_strategy)
+        monkeypatch.setattr(speedwagon.startup, "SingleWorkflowJSON", SingleWorkflowJSON)
+
+        run_command.run()
+        assert SingleWorkflowJSON.called is True
+
 
 def test_start_up_tab_editor(monkeypatch):
     standalone_tab_editor = Mock()
@@ -1251,3 +1008,53 @@ def test_start_up_tab_editor(monkeypatch):
 
         speedwagon.startup.main(argv=["tab-editor"])
         assert standalone_tab_editor.called is True
+
+
+class TestResolveSettings:
+    def test_get_settings(self, monkeypatch):
+        monkeypatch.setattr(
+            gui_startup.config.StandardConfigFileLocator,
+            "get_config_file",
+            lambda _ : "dummy.ini"
+        )
+        monkeypatch.setattr(
+            gui_startup.config.IniConfigManager,
+            "data",
+            lambda _: {}
+        )
+
+        resolver = gui_startup.ResolveSettings()
+        assert isinstance(resolver.get_settings(), dict)
+from pytestqt.qt_compat import qt_api
+class CustomQApplication(qt_api.QtWidgets.QApplication):
+    def __init__(self, *argv):
+        super().__init__(*argv)
+        self.custom_attr = "xxx"
+
+    def custom_function(self):
+        pass
+
+
+def test_gui_exceptions_hook(qtbot, monkeypatch):
+    monkeypatch.setattr(gui_startup.tb, "print_tb", Mock())
+    monkeypatch.setattr(gui_startup, "report_exception_dialog", Mock())
+    app = Mock()
+    gui_startup.gui_exceptions_hook(
+        BaseException,
+        Mock(name="BaseException"),
+        Mock(name="traceback: Optional[types.TracebackType]"),
+        app
+    )
+    app.exit.assert_called_with(1)
+
+
+def test_report_exception_dialog(qtbot, monkeypatch):
+    monkeypatch.setattr(
+        gui_startup.dialog.dialogs.SpeedwagonExceptionDialog,
+        "exec",
+        Mock(name='exec')
+    )
+    gui_startup.report_exception_dialog(RuntimeError())
+    assert \
+        gui_startup.dialog.dialogs.SpeedwagonExceptionDialog.exec.called \
+        is True
diff --git a/tests/frontend/test_logging_helpers.py b/tests/frontend/test_logging_helpers.py
new file mode 100644
index 000000000..a4afe0ec0
--- /dev/null
+++ b/tests/frontend/test_logging_helpers.py
@@ -0,0 +1,19 @@
+import pytest
+import logging
+
+QtWidgets = pytest.importorskip("PySide6.QtWidgets")
+QtCore = pytest.importorskip("PySide6.QtCore")
+
+from speedwagon.frontend.qtwidgets import logging_helpers
+
+
+class TestQtSignalLogHandler:
+    def test_signal_emitted(self, qtbot):
+        handler = logging_helpers.QtSignalLogHandler()
+        logger = logging.Logger("my_hander")
+        logger.setLevel(logging.INFO)
+        logger.addHandler(handler)
+        with qtbot.wait_signal(handler.signals.messageSent) as e:
+            logger.log(level=logging.INFO, msg="hello")
+        assert e.args[0] == "hello"
+
diff --git a/tests/frontend/test_models.py b/tests/frontend/test_models.py
index d641f0369..43fcefac4 100644
--- a/tests/frontend/test_models.py
+++ b/tests/frontend/test_models.py
@@ -1,57 +1,45 @@
-import configparser
 import json
 import os
+from typing import List, Any, Dict, Optional
 from unittest.mock import Mock
+import sys
 
-import pytest
-QtWidgets = pytest.importorskip("PySide6.QtWidgets")
-QtCore = pytest.importorskip("PySide6.QtCore")
-
-from speedwagon import job, workflow
-from speedwagon.frontend.qtwidgets import tabs, models
-
-
-def test_serialize_settings_model():
-    pytest.importorskip("PySide6.QtCore")
-
-    original_settings = {
-        "tessdata": "~/mytesseractdata"
-    }
-
-    # Mock up a model
-    cfg_parser = configparser.ConfigParser()
-    original_settings = cfg_parser["GLOBAL"] = original_settings
-    my_model = models.SettingsModel()
-    for k, v in original_settings.items():
-        my_model.add_setting(k, v)
-
-    # Serialize the model to ini file format
-    data = models.serialize_settings_model(my_model)
 
-    assert data is not None
+if sys.version_info < (3, 10):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
 
-    # Check that the new data is the same as original
-    new_config = configparser.ConfigParser()
-    new_config.read_string(data)
-    assert "GLOBAL" in new_config
-
-    for k, v in original_settings.items():
-        assert new_config["GLOBAL"][k] == v
 
+import pytest
+QtWidgets = pytest.importorskip("PySide6.QtWidgets")
+QtCore = pytest.importorskip("PySide6.QtCore")
+QtGui = pytest.importorskip("PySide6.QtGui")
+from PySide6.QtCore import Qt
+import speedwagon.config
+from speedwagon import workflow
+from speedwagon.frontend.qtwidgets import models
+from speedwagon.frontend.qtwidgets.models.settings import (
+    build_setting_qt_model,
+    WorkflowSettingsModel,
+)
 
 def test_build_setting_model_missing_file(tmpdir):
     dummy = str(os.path.join(tmpdir, "config.ini"))
     with pytest.raises(FileNotFoundError):
-        models.build_setting_qt_model(dummy)
+        build_setting_qt_model(dummy)
 
 
 class TestSettingsModel:
+    @pytest.fixture()
+    def model(self):
+        return  models.SettingsModel(None)
+
     @pytest.mark.parametrize("role", [
         QtCore.Qt.DisplayRole,
         QtCore.Qt.EditRole,
     ])
-    def test_data(self, role):
-        model = models.SettingsModel(None)
+    def test_data(self, role, model):
         model.add_setting("spam", "eggs")
         assert model.data(model.index(0, 0), role=role) == "spam"
 
@@ -59,16 +47,14 @@ def test_data(self, role):
         (0, "Key"),
         (1, "Value"),
     ])
-    def test_header_data(self, index, expected):
-        model = models.SettingsModel(None)
+    def test_header_data(self, index, expected, model):
         value = model.headerData(index,
                                  QtCore.Qt.Horizontal,
                                  role=QtCore.Qt.DisplayRole)
 
         assert value == expected
 
-    def test_set_data(self):
-        model = models.SettingsModel(None)
+    def test_set_data(self, model):
         model.add_setting("spam", "eggs")
         model.setData(model.index(0, 0), data="dumb")
         assert model._data[0][1] == "dumb"
@@ -77,192 +63,53 @@ def test_set_data(self):
         (0, QtCore.Qt.NoItemFlags),
         (1, QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable),
     ])
-    def test_flags(self, column, expected):
-        model = models.SettingsModel(None)
+    def test_flags(self, column, expected, model):
         model.add_setting("spam", "eggs")
         flags = model.flags(model.index(0, column))
         assert flags == expected
 
-    def test_settings_model_empty(self):
-        test_model = models.SettingsModel()
-        assert test_model.rowCount() == 0
-        assert test_model.columnCount() == 2
-        index = test_model.index(0, 0)
+    def test_settings_model_empty(self, model):
+        assert model.rowCount() == 0
+        assert model.columnCount() == 2
+        index = model.index(0, 0)
         assert index.data() is None
 
-    def test_settings_model_added(self):
-        test_model = models.SettingsModel()
-        test_model.add_setting("mysetting", "eggs")
-        assert test_model.rowCount() == 1
-        assert test_model.columnCount() == 2
-        assert test_model.index(0, 0).data() == "mysetting"
-        assert test_model.index(0, 1).data() == "eggs"
-
-        index = test_model.index(0, 1)
-        assert test_model.data(index) is None
-
-
-class TestTabsModel:
-    def test_tabs_model_iadd_tab(self):
-        test_model = models.TabsModel()
-        new_tab = tabs.TabData("My tab", models.WorkflowListModel2())
-        test_model += new_tab
-        assert test_model.rowCount() == 1
-
-    def test_tabs_model_delete_tab(self):
-        test_model = models.TabsModel()
-        new_tab = tabs.TabData("My tab", models.WorkflowListModel2())
-        # new_tab.tab_name =
-        test_model += new_tab
-
-        second_new_tab = tabs.TabData("second new tab",
-                                      models.WorkflowListModel2())
-
-        test_model += second_new_tab
-        assert test_model.rowCount() == 2
-
-        test_model -= second_new_tab
-        assert test_model.rowCount() == 1
-
-    def test_tabs_model_delete_all_tabs(self):
-        test_model = models.TabsModel()
-        first_new_tab = tabs.TabData("My tab", models.WorkflowListModel2())
-        test_model += first_new_tab
-
-        second_new_tab = tabs.TabData("second new tab",
-                                      models.WorkflowListModel2())
-
-        test_model += second_new_tab
-        assert test_model.rowCount() == 2
-
-        test_model -= second_new_tab
-        assert test_model.rowCount() == 1
-
-        test_model -= first_new_tab
-        assert test_model.rowCount() == 0
-
-    def test_model_contains(self):
-        from speedwagon.frontend.qtwidgets.tabs import TabData
-        model = models.TabsModel()
-        model.add_tab(TabData("dummy", Mock()))
-        assert ("dummy" in model) is True
-
-    def test_model_contains_false(self):
-        model = models.TabsModel()
-        assert ("dummy" in model) is False
-
-    def test_model_iadd_operator(self):
-        from speedwagon.frontend.qtwidgets.tabs import TabData
-        model = models.TabsModel()
-        model += TabData("dummy", Mock())
-        assert ("dummy" in model) is True
-
-    def test_model_isub_operator(self):
-        from speedwagon.frontend.qtwidgets.tabs import TabData
-        tab = TabData("dummy", Mock())
-        model = models.TabsModel()
-        model += tab
-        assert ("dummy" in model) is True
-        model -= tab
-        assert ("dummy" in model) is False
-
-    def test_model_data(self):
-        from speedwagon.frontend.qtwidgets.tabs import TabData
-        tab = TabData("dummy", Mock())
-        model = models.TabsModel()
-        model.add_tab(tab)
-        assert model.data(model.index(0, 0), role=QtCore.Qt.UserRole) == tab
-
-
-class TestItemListModel:
-    def test_create(self):
-        data = {
-            "spam": Mock()
-        }
-        new_model = models.ItemListModel(data)
-        assert new_model.jobs == list(data.values())
+    def test_settings_model_added(self, model):
+        model.add_setting("mysetting", "eggs")
+        assert model.rowCount() == 1
+        assert model.columnCount() == 2
+        assert model.index(0, 0).data() == "mysetting"
+        assert model.index(0, 1).data() == "eggs"
 
-    def test_columns_are_always_two(self):
-        data = {
-            "spam": Mock()
-        }
-        new_model = models.ItemListModel(data)
-        assert new_model.columnCount() == 2
+        index = model.index(0, 1)
+        assert model.data(index) is None
 
-    def test_columns_row_count_is_the_size_of_data(self):
-        data = {
-            "spam": Mock()
-        }
-        new_model = models.ItemListModel(data)
-        assert new_model.rowCount() == len(data)
-
-
-class TestWorkflowListModel2:
-
-    def test_workflow_list_model2_iadd(self):
-        workflows_model = models.WorkflowListModel2()
-        workflows = job.available_workflows()
-        workflows_model += workflows["Hathi Prep"]
-        assert workflows_model.rowCount() == 1
-
-    def test_workflow_list_model2_add(self):
-        workflows_model = models.WorkflowListModel2()
-        workflows = job.available_workflows()
-        workflows_model.add_workflow(workflows["Hathi Prep"])
-        assert workflows_model.rowCount() == 1
-
-    def test_workflow_list_model2_remove(self):
-        workflows_model = models.WorkflowListModel2()
-        workflows = job.available_workflows()
-
-        workflows_model.add_workflow(workflows["Hathi Prep"])
-        jp2_workflow = workflows['Make JP2']
-        workflows_model.add_workflow(jp2_workflow)
-        assert workflows_model.rowCount() == 2
-
-        workflows_model.remove_workflow(jp2_workflow)
-        assert workflows_model.rowCount() == 1
-
-    def test_workflow_list_model2_isub(self):
-        workflows_model = models.WorkflowListModel2()
-        workflows = job.available_workflows()
-
-        workflows_model.add_workflow(workflows["Hathi Prep"])
-        jp2_workflow = workflows['Make JP2']
-        workflows_model += jp2_workflow
-        assert workflows_model.rowCount() == 2
-
-        workflows_model -= jp2_workflow
-        assert workflows_model.rowCount() == 1
-
-    def test_data(self):
-        workflows_model = models.WorkflowListModel2()
-        mock_workflow = Mock()
-        mock_workflow.name = "Spam"
-        workflows_model.add_workflow(mock_workflow)
-
-        assert workflows_model.data(
-            workflows_model.index(0, 0),
-            role=QtCore.Qt.DisplayRole
-        ) == "Spam"
-
-    def test_sort_defaults_alpha_by_name(self):
-        workflows_model = models.WorkflowListModel2()
-        mock_spam_workflow = Mock()
-        mock_spam_workflow.name = "Spam"
-        workflows_model.add_workflow(mock_spam_workflow)
-
-        mock_bacon_workflow = Mock()
-        mock_bacon_workflow.name = "Bacon"
-        workflows_model.add_workflow(mock_bacon_workflow)
-        workflows_model.sort()
-        assert workflows_model.data(
-            workflows_model.index(0, 0),
-            role=QtCore.Qt.DisplayRole
-        ) == "Bacon" and workflows_model.data(
-            workflows_model.index(1, 0),
-            role=QtCore.Qt.DisplayRole
-        ) == "Spam"
+    def test_modified(self, model):
+        model.add_setting("spam", "eggs")
+        model.setData(model.index(0, 0), data="dumb")
+        assert model.data_modified is True
+
+    def test_not_modified(self, model):
+        model.add_setting("spam", "eggs")
+        assert model.data_modified is False
+
+    def test_not_modified_after_reverted(self, model):
+        model.add_setting("spam", "eggs")
+        model.setData(model.index(0, 0), data="dumb")
+        # revert back to original
+        model.setData(model.index(0, 0), data="eggs")
+        assert model.data_modified is False
+
+    def test_data_for_invalid_index_is_none(self, model):
+        index = model.index(-1, -1)
+        assert model.data(index) is None
+
+    def test_no_vertical_header_data(self, model):
+        assert model.headerData(0, QtCore.Qt.Orientation.Vertical) is None
+
+    def test_set_data_on_invalid_data_returns_false(self, model):
+        index = model.index(-1, -1)
+        assert model.setData(index, "data") is False
 
 
 class TestToolOptionsModel4:
@@ -353,49 +200,1335 @@ def test_set_data(self, data):
         assert starting_value is None and changed_value == "spam"
 
 
-def test_get_settings_errors():
-    file_selection_option = workflow.FileSelectData(
-        'Checksum File',
-        required=True
+def test_build_setting_model(tmpdir):
+
+    dummy = str(os.path.join(tmpdir, "config.ini"))
+    empty_config_data = """[GLOBAL]
+debug: False
+        """
+    with open(dummy, "w") as wf:
+        wf.write(empty_config_data)
+    model = build_setting_qt_model(dummy)
+    assert isinstance(model, models.SettingsModel)
+
+    assert model is not None
+
+
+class TestPluginActivationModel:
+    def test_adding_plugin_adds_row(self):
+        plugin_model = models.PluginActivationModel()
+        assert plugin_model.rowCount() == 0
+        plugin_entry = Mock(spec=metadata.EntryPoint)
+        plugin_model.add_entry_point(plugin_entry)
+        assert plugin_model.rowCount() == 1
+
+    def test_name_is_displayed(self):
+        plugin_model = models.PluginActivationModel()
+        plugin_entry = Mock(spec=metadata.EntryPoint)
+        plugin_entry.name = "spam"
+        plugin_model.add_entry_point(plugin_entry)
+        value = plugin_model.data(plugin_model.index(0), QtCore.Qt.ItemDataRole.DisplayRole)
+        assert "spam" in value
+
+    @pytest.mark.parametrize(
+        "expected_flag",
+        [
+            QtCore.Qt.ItemFlag.ItemIsUserCheckable,
+            QtCore.Qt.ItemFlag.ItemIsSelectable,
+            QtCore.Qt.ItemFlag.ItemIsEnabled,
+         ]
     )
-    # Note that no value has been selected for a required field
-    file_selection_option.value = None
-
-    data = [
-        file_selection_option
-    ]
-    model = models.ToolOptionsModel4(data)
-    error = models.get_settings_errors(
-        model,
+    def test_flags(self, expected_flag):
+        plugin_model = models.PluginActivationModel()
+        plugin_entry = Mock(spec=metadata.EntryPoint)
+        plugin_entry.name = "spam"
+        plugin_model.add_entry_point(plugin_entry)
+        assert expected_flag in plugin_model.flags(plugin_model.index(0))
+
+    def test_default_checked_role_unchecked(self):
+        plugin_model = models.PluginActivationModel()
+        plugin_entry = Mock(spec=metadata.EntryPoint)
+        plugin_entry.name = "spam"
+        plugin_model.add_entry_point(plugin_entry)
+        assert \
+            plugin_model.data(
+                plugin_model.index(0),
+                role=QtCore.Qt.ItemDataRole.CheckStateRole
+            ) == QtCore.Qt.CheckState.Unchecked
+
+    @pytest.mark.parametrize(
+        "role, attribute",
         [
-            models.check_required_settings_have_values
+            (models.PluginActivationModel.ModuleRole, "module"),
+            (QtCore.Qt.ItemDataRole.DisplayRole, "name"),
         ]
     )
-    assert len(error) > 0
+    def test_data_role(self, role, attribute):
+        plugin_model = models.PluginActivationModel()
+        plugin_entry = Mock(spec=metadata.EntryPoint)
+        plugin_entry.name = "spam"
 
+        plugin_model.add_entry_point(plugin_entry)
+        assert \
+            plugin_model.data(
+                plugin_model.index(0),
+                role=role
+            ) == getattr(plugin_entry, attribute)
 
-def test_check_required_settings_have_values_not_required_returns_nothing():
-    option_data = Mock(workflow.AbsOutputOptionDataType, required=False)
-    result = models.check_required_settings_have_values(option_data)
-    assert result is None
 
+class TestTabsTreeModel:
 
-def test_check_required_settings_have_values_required_and_has_value_returns_nothing():
-    option_data = Mock(workflow.AbsOutputOptionDataType, required=True)
-    option_data.value = "something"
-    result = models.check_required_settings_have_values(option_data)
-    assert result is None
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "spam"
+        description = "spam description"
 
+    class BaconWorkflow(speedwagon.Workflow):
+        name = "bacon"
+        description = "bacon description"
 
-def test_build_setting_model(tmpdir):
 
-    dummy = str(os.path.join(tmpdir, "config.ini"))
-    empty_config_data = """[GLOBAL]
-debug: False
-        """
-    with open(dummy, "w") as wf:
-        wf.write(empty_config_data)
-    model = models.build_setting_qt_model(dummy)
-    assert isinstance(model, models.SettingsModel)
+    @pytest.fixture()
+    def model(self):
+        return models.TabsTreeModel()
+
+    def test_add_empty_tab(self, qtbot, model):
+        starting_amount = model.rowCount()
+        model.append_workflow_tab("tab_name")
+        ending_amount = model.rowCount()
+        assert all(
+            (
+                starting_amount == 0,
+                ending_amount == 1
+            )
+        ), f"Should start with 0, got {starting_amount}. " \
+           f"Should end with 1, got {ending_amount}"
+
+    def test_add_workflow_tab_with_list_workflows(self, qtbot, model):
+        starting_workflow_row_count = model.rowCount(model.index(0, 0))
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow,
+            ]
+        )
+        ending_workflow_row_count = model.rowCount(model.index(0, 0))
+        assert all(
+            (
+                starting_workflow_row_count == 0,
+                ending_workflow_row_count == 2,
+            )
+        ), f"Expected starting workflow row 0, got {starting_workflow_row_count}. " \
+           f"Expected workflows in tab after adding 2, got{ending_workflow_row_count}"
+
+    def test_get_tab_missing_returns_none(self, qtbot):
+        model = models.TabsTreeModel()
+        assert model.get_tab(tab_name="Not valid") is None
+
+    def test_get_tab(self, qtbot, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+            ]
+        )
+        assert model.get_tab("Dummy tab").name == "Dummy tab"
+
+    def test_add_workflow(self, qtbot, model):
+        model.append_workflow_tab("Dummy tab")
+        assert model.rowCount(model.index(0, 0)) == 0
+        model.append_workflow_to_tab(
+            "Dummy tab",
+            TestTabsTreeModel.SpamWorkflow
+        )
+        assert model.rowCount(model.index(0, 0)) == 1
+
+    def test_tab_names(self, qtbot):
+        model = models.TabsTreeModel()
+        model.append_workflow_tab("Dummy tab")
+        assert model.tab_names == ["Dummy tab"]
+
+    def test_remove_row(self):
+        model = models.TabsTreeModel()
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow,
+            ]
+        )
+        assert model.rowCount() == 1
+        model.removeRow(0)
+        assert model.rowCount() == 0
+
+    def test_get_item(self, qtbot, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow,
+            ]
+        )
+        tab_item: models.TabStandardItem = model.get_item(model.index(0, 0))
+        assert all(
+         [
+             tab_item.child(0).name == "spam",
+             tab_item.child(1).name == "bacon"
+         ]
+        ), f'Expected child 0 to be "spam", got "{tab_item.child(0).name}". ' \
+           f'Expected child 1 to be "bacon", got "{tab_item.child(1).name}".'
+
+    def test_modified(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow
+            ]
+        )
+        assert model.data_modified is True
+
+    def test_reset_modified(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow
+            ]
+        )
+        model.reset_modified()
+        assert model.data_modified is False
+
+    def test_len_tabs(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow
+            ]
+        )
+        assert len(model) == 1
+
+    def test_index(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow
+            ]
+        )
+        assert model[0].name == "Dummy tab"
+
+    def test_invalid_index_raises(self):
+        model = models.TabsTreeModel()
+        with pytest.raises(IndexError):
+            model[0]
+
+    def test_modified_children(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+            ]
+        )
+        model.reset_modified()
+        dummy_tab = model[0]
+        dummy_tab.append_workflow(TestTabsTreeModel.BaconWorkflow)
+        assert model.data_modified is True
+
+    def test_tab_information(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+            ]
+        )
+        assert model.tab_information()[0].tab_name == "Dummy tab"
+
+    @pytest.mark.parametrize(
+        "top_row, sub_row, sub_column, expected_value",
+        [
+            (0, 0, 0, "spam"),
+            (0, 1, 0, "bacon"),
+            (0, 1, 1, "bacon description"),
+        ]
+    )
+    def test_data(self, top_row, sub_row, sub_column, expected_value, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow,
+            ]
+        )
+        assert model.data(
+            model.index(sub_row, sub_column, parent=model.index(top_row, 0)),
+        ) == expected_value
+
+    def test_data_with_invalid_index_get_none(self, model):
+        assert model.data(model.index(-1)) is None
+
+    def test_data_with_WorkflowClassRole(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+                TestTabsTreeModel.BaconWorkflow,
+            ]
+        )
+        assert model.data(
+            model.index(0, 0, parent=model.index(0, 0)),
+            role=models.WorkflowClassRole
+        ) == TestTabsTreeModel.SpamWorkflow
+
+    @pytest.mark.parametrize(
+        "section, expected_value",
+        [
+            (0, "Name"),
+            (1, "Description"),
+        ]
+    )
+    def test_header_data(self, section, expected_value, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+            ]
+        )
+        assert \
+            model.headerData(
+                section,
+                QtCore.Qt.Orientation.Horizontal,
+                QtCore.Qt.ItemDataRole.DisplayRole
+            ) == expected_value
+
+    def test_parent_no_child(self, model):
+        assert model.parent().isValid() is False
+
+    def test_parent_child_not_valid(self):
+        model = models.TabsTreeModel()
+        child = Mock(isValid=Mock(return_value=False))
+        assert isinstance(model.parent(child), QtCore.QModelIndex)
+
+    def test_parent_child_valid_child(self, model):
+        model.get_item = Mock(return_value=models.TabStandardItem())
+        child = Mock(isValid=Mock(return_value=True))
+        assert isinstance(model.parent(child), QtCore.QModelIndex)
+
+    def test_clear(self, model):
+        model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabsTreeModel.SpamWorkflow,
+            ]
+        )
+        assert model.rowCount() == 1
+        model.clear()
+        assert model.rowCount() == 0
+
+    def test_parent_not_valid_child(self, model):
+        assert model.parent(model.index(-1,-1)).isValid() is False
+
+    def test_set_data(self, model):
+        index = model.index(0)
+        assert model.setData(
+            index,
+            value=self.SpamWorkflow,
+            role=models.common.WorkflowClassRole
+        ) is True
+
+    def test_set_data_text(self, model):
+        index = model.index(0)
+        assert model.setData(
+            index,
+            value="Invalid data for this role",
+            role=QtGui.Qt.ItemDataRole.CheckStateRole
+        ) is False
+
+    def test_row_count_higher_column_parent(self, model):
+        index = Mock(
+            isValid=Mock(return_value=True), column=Mock(return_value=3)
+        )
+        assert model.rowCount(index) == 0
+
+class TestTabStandardItem:
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "spam"
+
+    def test_append_increase_row_count(self):
+        item = models.TabStandardItem()
+        assert item.rowCount() == 0
+        item.append_workflow(TestTabStandardItem.SpamWorkflow)
+        assert item.rowCount() == 1
+
+    def test_unable_to_append_workflow_already_added(self):
+
+        item = models.TabStandardItem()
+        assert item.rowCount() == 0
+        item.append_workflow(TestTabStandardItem.SpamWorkflow)
+        item.append_workflow(TestTabStandardItem.SpamWorkflow)
+        assert item.rowCount() == 1
+
+    def test_remove_decreases_row_count(self):
+
+        item = models.TabStandardItem()
+        item.append_workflow(TestTabStandardItem.SpamWorkflow)
+        item.remove_workflow(TestTabStandardItem.SpamWorkflow)
+        assert item.rowCount() == 0
+
+    def test_data_changed(self, qtbot):
+
+        item = models.TabStandardItem()
+        model = QtGui.QStandardItemModel()
+        model.appendRow(item)
+        with qtbot.wait_signal(model.dataChanged):
+            item.append_workflow(TestTabStandardItem.SpamWorkflow)
+
+
+class TestWorkflowListProxyModel:
+
+    class DummyWorkflow(speedwagon.Workflow):
+        name = "dummy 1"
+
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "Span"
+
+
+    def test_name_no_model(self, qtbot):
+        proxy_model = models.WorkflowListProxyModel()
+        assert proxy_model.current_tab_name is None
+
+    def test_invalid_set_by_name_raises(self, qtbot):
+        base_model = models.TabsTreeModel()
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        with pytest.raises(ValueError):
+            proxy_model.set_by_name("not a valid tab")
+
+    @pytest.mark.parametrize(
+        "tab_name",
+        (
+            [
+                "Spam tab",
+                "Bacon tab"
+            ]
+        )
+    )
+    def test_set_by_name(self, qtbot, tab_name):
+        base_model = models.TabsTreeModel()
+
+        base_model.append_workflow_tab(
+            "Spam tab",
+            [
+                TestWorkflowListProxyModel.DummyWorkflow,
+            ]
+        )
+        base_model.append_workflow_tab(
+            "Bacon tab",
+            [
+                TestWorkflowListProxyModel.DummyWorkflow,
+            ]
+        )
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_model.set_by_name(tab_name)
+        assert proxy_model.current_tab_name == tab_name
+
+    def test_map_to_source(self, qtbot):
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestWorkflowListProxyModel.DummyWorkflow,
+                TestWorkflowListProxyModel.SpamWorkflow,
+            ]
+        )
+        item_index = base_model.index(0,0, parent=base_model.index(0,0))
+        workflow_item: models.WorkflowItem = base_model.get_item(item_index)
+        assert workflow_item.name == "dummy 1"
+
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_index = proxy_model.index(0, 0)
+        assert proxy_model.data(proxy_index) == "dummy 1"
+        assert proxy_model.mapToSource(proxy_index) == item_index
+
+    def test_map_to_source_with_no_model_produces_index(self):
+        proxy_model = models.WorkflowListProxyModel()
+        assert isinstance(
+            proxy_model.mapToSource(proxy_model.index(0,0)),
+            QtCore.QModelIndex
+        )
+
+    def test_map_from_source_with_invalid_index_produces_index(self):
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_index = Mock()
+        proxy_index.isValid = Mock(return_value=False)
+        assert isinstance(
+            proxy_model.mapFromSource(proxy_index),
+            QtCore.QModelIndex
+        )
+
+    def test_index_invalid_parent(self):
+        base_model = models.TabsTreeModel()
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        parent = Mock(name="parent", isValid=Mock(return_value=True))
+        assert isinstance(proxy_model.index(0, 0, parent), QtCore.QModelIndex)
+
+    def test_map_from_source(self, qtbot):
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabProxyModel.DummyWorkflow,
+                TestTabProxyModel.SpamWorkflow
+            ]
+        )
+        item_index = base_model.index(1,0, parent=base_model.index(0,0))
+        workflow_item: models.WorkflowItem = base_model.get_item(item_index)
+        assert workflow_item.name == "Spam"
+
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_index = proxy_model.index(1, 0)
+        assert proxy_model.data(proxy_index) == "Spam"
+        assert proxy_model.mapFromSource(item_index) == proxy_index
+
+    def test_add_workflow(self):
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabProxyModel.DummyWorkflow,
+            ]
+        )
+
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_model.set_by_name("Dummy tab")
+        assert proxy_model.current_tab_name == "Dummy tab"
+        proxy_model.add_workflow(TestTabProxyModel.SpamWorkflow)
+        assert proxy_model.rowCount() == 2
+
+    def test_row_count_no_source_model(self):
+        proxy_model = models.WorkflowListProxyModel()
+        assert proxy_model.rowCount() == 0
+
+    def test_add_workflow_affects_source(self):
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestWorkflowListProxyModel.DummyWorkflow,
+            ]
+        )
+
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_model.set_by_name("Dummy tab")
+
+        starting_row_count = base_model.rowCount(base_model.index(0,0)) == 1
+        proxy_model.add_workflow(TestWorkflowListProxyModel.SpamWorkflow)
+        after_appending_row_count = base_model.rowCount(base_model.index(0,0))
+
+        expected = {
+            "start row count": 1,
+            "row count after proxy appends workflow": 2
+        }
+
+        actual = {
+            "start row count": starting_row_count,
+            "row count after proxy appends workflow": after_appending_row_count
+        }
+        assert expected == actual
+
+    def test_remove_workflow(self):
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestWorkflowListProxyModel.DummyWorkflow,
+            ]
+        )
+
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(base_model)
+        proxy_model.set_by_name("Dummy tab")
+        assert proxy_model.current_tab_name == "Dummy tab"
+        proxy_model.remove_workflow(TestWorkflowListProxyModel.DummyWorkflow)
+        assert proxy_model.rowCount() == 0
+
+    def test_set_index_without_a_model_is_a_noop(self, qtbot):
+        proxy_model = models.WorkflowListProxyModel()
+        with qtbot.assert_not_emitted(proxy_model.dataChanged):
+            proxy_model.set_tab_index(0)
+
+    def test_set_by_name_without_a_model_is_a_noop(self, qtbot):
+        proxy_model = models.WorkflowListProxyModel()
+        with qtbot.assert_not_emitted(proxy_model.dataChanged):
+            proxy_model.set_by_name("spam")
+
+    def test_row_count_for_no_model_is_zero(self):
+        proxy_model = models.WorkflowListProxyModel()
+        assert proxy_model.rowCount() == 0
+
+    def test_column_count_for_no_model_is_zero(self):
+        proxy_model = models.WorkflowListProxyModel()
+        assert proxy_model.columnCount() == 0
+
+    def test_column_count_for_any_model_is_one(self):
+        main_model = models.TabsTreeModel()
+        proxy_model = models.WorkflowListProxyModel()
+        proxy_model.setSourceModel(main_model)
+        assert proxy_model.columnCount() == 1
+
+    def test_parent_q_object(self):
+        proxy_model = models.WorkflowListProxyModel()
+        assert isinstance(proxy_model.parent(), QtCore.QObject)
+
+    def test_parent_index(self):
+        proxy_model = models.WorkflowListProxyModel()
+        index = proxy_model.createIndex(0, 0, 0)
+        assert isinstance(proxy_model.parent(index), QtCore.QModelIndex)
+
+    def test_append_workflow_without_a_model_is_a_runtime_error(self):
+        proxy_model = models.WorkflowListProxyModel()
+        with pytest.raises(RuntimeError):
+            proxy_model.add_workflow(TestWorkflowListProxyModel.DummyWorkflow)
+
+    def test_remove_workflow_without_a_model_is_a_runtime_error(self):
+        proxy_model = models.WorkflowListProxyModel()
+        with pytest.raises(RuntimeError):
+            proxy_model.remove_workflow(TestWorkflowListProxyModel.DummyWorkflow)
+
+
+class TestTabProxyModel:
+
+    class DummyWorkflow(speedwagon.Workflow):
+        name = "Dummy 1"
+
+
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "Spam"
+
+
+    @pytest.fixture()
+    def base_model(self):
+        return models.TabsTreeModel()
+
+    def test_set_source_tab(self, base_model):
+
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                TestTabProxyModel.DummyWorkflow,
+            ]
+        )
+        tab_model = models.TabProxyModel()
+
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Dummy tab")
+
+        assert tab_model.rowCount() == 1
+
+    def test_mapFromSource(self, base_model):
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        base_model.append_workflow_tab(
+            "Spam tab",
+            [TestTabProxyModel.SpamWorkflow]
+        )
+        tab_model = models.TabProxyModel()
+
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Spam tab")
+        source_index = base_model.index(0,0, parent=base_model.index(1))
+        assert tab_model.mapFromSource(source_index).row() == 0
+
+    def test_mapFromSource_name(self, base_model):
+
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        base_model.append_workflow_tab(
+            "Spam tab",
+            [TestTabProxyModel.SpamWorkflow]
+        )
+
+        tab_model = models.TabProxyModel()
+
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Spam tab")
+
+        source_index = base_model.index(0, 0, parent=base_model.index(1, 0))
+
+        assert tab_model.data(tab_model.mapFromSource(source_index)) == "Spam"
+
+    def test_mapToSource(self, base_model):
+
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        base_model.append_workflow_tab(
+            "Spam tab",
+            [TestTabProxyModel.SpamWorkflow]
+        )
+        tab_model = models.TabProxyModel()
+
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Spam tab")
+
+        assert tab_model.mapToSource(
+            tab_model.index(0, 0)
+        ) == base_model.index(0,0, parent=base_model.index(1))
+
+    def test_add_workflow(self, base_model):
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Dummy tab")
+        assert base_model.rowCount(base_model.index(0)) == 1
+        tab_model.add_workflow(TestTabProxyModel.SpamWorkflow)
+        assert base_model.rowCount(base_model.index(0)) == 2
+        assert base_model.data(base_model.index(1, 0, parent=base_model.index(0))) == "Spam"
+
+    def test_add_workflow_duplicates_is_noop(self, base_model):
+
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Dummy tab")
+        assert base_model.rowCount(base_model.index(0)) == 1
+
+        assert base_model.rowCount(base_model.index(0)) == 1
+        tab_model.add_workflow(TestTabProxyModel.SpamWorkflow)
+        tab_model.add_workflow(TestTabProxyModel.SpamWorkflow)
+        tab_model.add_workflow(TestTabProxyModel.SpamWorkflow)
+        assert base_model.rowCount(base_model.index(0)) == 2
+        assert base_model.data(base_model.index(1, 0, parent=base_model.index(0))) == "Spam"
+
+    def test_remove_workflow(self, base_model):
+
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Dummy tab")
+
+        assert base_model.rowCount(base_model.index(0)) == 1
+        tab_model.remove_workflow(TestTabProxyModel.DummyWorkflow)
+        assert base_model.rowCount(base_model.index(0)) == 0
+
+    def test_get_source_tab_index(self, base_model):
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [TestTabProxyModel.DummyWorkflow]
+        )
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        assert base_model.data(
+            tab_model.get_source_tab_index("Dummy tab")
+        ) == "Dummy tab"
+
+    def test_get_source_tab_index_invalid_index(self, base_model):
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        assert tab_model.get_source_tab_index("Dummy tab").isValid() is False
+
+    def test_add_workflow_without_source_model_raises(self):
+        tab_model = models.TabProxyModel()
+        with pytest.raises(RuntimeError):
+            tab_model.add_workflow(TestTabProxyModel.DummyWorkflow)
+
+    def test_sort(self, base_model):
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                self.SpamWorkflow,
+                self.DummyWorkflow,
+            ]
+        )
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        tab_model.set_source_tab("Dummy tab")
+        assert tab_model.data(tab_model.index(0)) == "Spam"
+        tab_model.sort(0, QtCore.Qt.SortOrder.AscendingOrder)
+        assert tab_model.data(tab_model.index(0)) == "Dummy 1"
+
+    def test_sort_noop_on_no_source_tab(self, base_model):
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                self.SpamWorkflow,
+                self.DummyWorkflow,
+            ]
+        )
+        tab_model = models.TabProxyModel()
+        tab_model.setSourceModel(base_model)
+        tab_model.source_tab = None
+        tab_model.sort(0)
+
+    def test_sort_no_base_model(self):
+        tab_model = models.TabProxyModel()
+        tab_model.set_source_tab('invalid tab')
+        tab_model.sort(0)
+
+
+class TestWorkflowList:
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "Spam"
+
+    def test_add_workflow_adds_row(self):
+        model = models.WorkflowList()
+        assert model.rowCount() == 0
+        model.add_workflow(TestWorkflowList.SpamWorkflow)
+        assert model.rowCount() == 1
+
+    def test_add_workflow_get_data(self):
+        model = models.WorkflowList()
+        assert model.rowCount() == 0
+        model.add_workflow(TestWorkflowList.SpamWorkflow)
+        assert model.data(model.index(0)) == "Spam"
+
+    def test_insert(self):
+        model = models.WorkflowList()
+        model.insertRow(model.rowCount())
+        assert model.rowCount() == 1
+
+    def test_setData(self):
+        model = models.WorkflowList()
+        model.insertRow(model.rowCount())
+        first_item_index = model.index(0, 0)
+        model.setData(first_item_index, TestWorkflowList.SpamWorkflow)
+        assert model.data(first_item_index) == "Spam"
+
+    def test_remove(self):
+        model = models.WorkflowList()
+        model.insertRow(model.rowCount())
+        assert model.rowCount() == 1
+        model.removeRow(0)
+        assert model.rowCount() == 0
+
+
+# def sample_model():
+#     return WorkflowSettingsModel2()
+#
+# only_real_model = pytest.mark.skipif(isinstance(sample_model(), WorkflowSettingsStandardModel), reason="wrong model")
+
+
+class TestWorkflowSettingsModel:
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "Spam"
+
+        def workflow_options(self) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
+
+            return [
+                speedwagon.workflow.TextLineEditData("Dummy config", required=True)
+            ]
+
+        def discover_task_metadata(
+                self,
+                initial_results: List[Any],
+                additional_data: Dict[str, Any],
+                **user_args) -> List[dict]:
+            return []
+
+    class BaconWorkflow(speedwagon.Workflow):
+        name = "Bacon"
+
+        def workflow_options(self) -> List[speedwagon.workflow.AbsOutputOptionDataType]:
+
+            return [
+                speedwagon.workflow.TextLineEditData("Bacon config 1", required=True),
+                speedwagon.workflow.TextLineEditData("Bacon config 2", required=True)
+            ]
+
+        def discover_task_metadata(
+                self,
+                initial_results: List[Any],
+                additional_data: Dict[str, Any],
+                **user_args) -> List[dict]:
+            return []
+
+    class NoConfigWorkflow(speedwagon.Workflow):
+        name = "Workflow that has no config options"
+        def discover_task_metadata(
+                self,
+                initial_results: List[Any],
+                additional_data: Dict[str, Any],
+                **user_args) -> List[dict]:
+            return []
+
+    @pytest.fixture()
+    def model(self):
+        return WorkflowSettingsModel()
+
+    def test_starting_column_count(self, model):
+        assert model.columnCount() == 2
+
+    def test_column_count(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.columnCount() == 2
+
+    def test_column_count_no_items(self, model):
+        model.add_workflow(self.NoConfigWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        assert model.columnCount() == 2
+
+    def test_column_count_child(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.columnCount(model.index(0,0)) == 2
+
+    def test_starting_row_count(self, model):
+        assert model.rowCount() == 0
+
+    def test_row_count_after_adding(self, model, qtbot):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.rowCount() == 1
+
+    def test_row_count_with_parent_after_adding(self, model):
+        model.add_workflow(self.BaconWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(0, 0)
+        assert model.rowCount(index) == 2
+
+    @pytest.mark.parametrize(
+        "row, expected_column_count",
+        [
+            (0, 2),
+            (1, 2),
+        ]
+    )
+    def test_column_count_with_parent_after_adding(self, model, row, expected_column_count):
+        model.add_workflow(self.BaconWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        parent = model.index(row, 0)
+        assert model.columnCount(parent) == expected_column_count
+
+    def test_column_count_with_parent(self, model):
+        model.add_workflow(self.BaconWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        parent = model.index(0,0)
+        index = model.index(0, 0, parent=parent)
+        assert model.columnCount(index) == 2
+
+    @pytest.mark.parametrize(
+        "row, column, expected_text",
+        [
+            (0, 0, "Bacon"),
+            (1, 0, "Spam"),
+            (2, 0, None),
+            (0, 1, None),
+            (1, 1, None),
+            (2, 1, None),
+        ]
+    )
+    def test_data_display_top_level(self, model, row, column, expected_text):
+        model.add_workflow(self.BaconWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(row, column)
+        assert model.data(index) == expected_text
+
+    @pytest.mark.parametrize(
+        [
+            "workflow_row",
+            "workflow_column",
+            "child_row",
+            "child_column",
+            "role",
+            "expected_text"
+        ],
+        [
+            (0, 0, 0, 0, QtCore.Qt.DisplayRole, "Bacon config 1"),
+            (0, 0, 0, 0, QtCore.Qt.EditRole, "Bacon config 1"),
+            (0, 0, 1, 0, QtCore.Qt.DisplayRole, "Bacon config 2"),
+            (0, 0, 1, 0, QtCore.Qt.EditRole, "Bacon config 2"),
+            (0, 0, 2, 0, QtCore.Qt.DisplayRole, None),
+
+            (1, 0, 0, 0, QtCore.Qt.DisplayRole, "Dummy config"),
+            (1, 0, 1, 0, QtCore.Qt.DisplayRole, None),
+            (1, 0, 1, 0, QtCore.Qt.DisplayRole, None),
+
+            (0, 1, 0, 0, QtCore.Qt.DisplayRole, None),
+            (0, 1, 0, 1, QtCore.Qt.DisplayRole, None),
+            (0, 1, 0, 2, QtCore.Qt.DisplayRole, None),
+            (0, 1, 1, 0, QtCore.Qt.DisplayRole, None),
+            (0, 1, 1, 1, QtCore.Qt.DisplayRole, None),
+            (0, 1, 1, 2, QtCore.Qt.DisplayRole, None),
+            (0, 2, 0, 0, QtCore.Qt.DisplayRole, None),
+            (0, 2, 0, 1, QtCore.Qt.DisplayRole, None),
+            (0, 2, 0, 2, QtCore.Qt.DisplayRole, None),
+            (0, 0, 0, 1, QtCore.Qt.DisplayRole, None),
+            (1, 0, 0, 1, QtCore.Qt.DisplayRole, None),
+            (2, 0, 0, 0, QtCore.Qt.DisplayRole, None),
+            (0, 1, 0, 0, QtCore.Qt.DisplayRole, None),
+            (1, 1, 0, 0, QtCore.Qt.DisplayRole, None),
+            (2, 1, 0, 0, QtCore.Qt.DisplayRole, None),
+        ]
+    )
+    def test_data_display_one_level_down(self, model, workflow_row, workflow_column, child_row, child_column, role, expected_text):
+        model.add_workflow(self.BaconWorkflow())
+        model.add_workflow(self.SpamWorkflow())
+        parent_index = model.index(workflow_row, workflow_column)
+        index = model.index(child_row, child_column, parent=parent_index)
+        model.data(index, role=role) ==  expected_text
+
+    def test_index_without_anything(self, model):
+        index = model.index(0, 0)
+        assert index.isValid() is False
+
+    @pytest.mark.parametrize("index_values, expected", [
+        ((0,0), True),
+        ((0,1), True),
+    ])
+    def test_index_workflow_level(self, model, index_values, expected):
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(*index_values)
+        assert index.isValid() is expected
+
+    @pytest.mark.parametrize("workflow_options_index_values, expected", [
+        ((0, 0), True),
+        ((1, 0), False),
+    ])
+    def test_index_options_level(self, model, workflow_options_index_values, expected):
+        model.add_workflow(self.SpamWorkflow())
+        parent_index = model.index(0, 0)
+        index = model.index(
+            *workflow_options_index_values, parent=parent_index
+        )
+        assert index.isValid() is expected, f"data for model = {model.data(index)}"
+
+    @pytest.mark.parametrize("column, expected_valid", [
+        (0, True),
+        (1, True),
+        (2, False)
+    ])
+    def test_second_level_index(self, model, column, expected_valid):
+        model.add_workflow(self.SpamWorkflow())
+        parent = model.index(0, 0)
+        index = model.index(0, column, parent=parent)
+        assert index.isValid() is expected_valid, f"data for model = {model.data(index)}"
+
+    def test_add_workflow(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.rowCount() == 1
+
+    def test_remove_workflow(self, model):
+        workflow = self.SpamWorkflow()
+        model.add_workflow(workflow)
+        assert model.rowCount() == 1
+        model.remove_workflow(workflow)
+        assert model.rowCount() == 0
+
+    @pytest.mark.parametrize(
+        "column,expected_value",
+        [
+            (0, "Spam"),
+            (1, None),
+            (2, None)
+        ]
+    )
+    def test_top_level_data(self, model, column, expected_value):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.data(model.index(0, column)) == expected_value
+
+    @pytest.mark.parametrize(
+        "index_values, parent_index_values, expected_value",
+        [
+            ((0, 0), None, "Spam"),
+            ((0, 1), None, None),
+            ((0, 2), None, None),
+            ((0, 0), (0, 0), 'Dummy config'),
+        ]
+    )
+    def test_data(self, model, index_values, parent_index_values, expected_value):
+        model.add_workflow(self.SpamWorkflow())
+        parent_index = \
+            model.index(*parent_index_values) \
+                if parent_index_values is not None \
+            else QtCore.QModelIndex()
+        index = model.index(*index_values, parent=parent_index)
+        assert model.data(index) == expected_value
+
+    def test_second_level(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        workflow_index = model.index(0, 0)
+        workflow_metadata_data_index = model.index(0, 0, parent=workflow_index)
+        assert model.data(workflow_metadata_data_index) == "Dummy config"
+
+    def test_setting(self, model):
+        backend = Mock(get=lambda key: "foo" if key == "Dummy config" else None)
+        workflow = self.SpamWorkflow()
+        workflow.set_options_backend(backend)
+        model.add_workflow(workflow)
+        workflow_index = model.index(0, 0)
+        workflow_metadata_data_index = model.index(0, 1, parent=workflow_index)
+        assert model.data(workflow_metadata_data_index) == "foo"
+    def test_items_with_settings(self, model):
+        assert model.rowCount() == 0
+        model.add_workflow(self.SpamWorkflow())
+        assert model.rowCount() == 1
+        model.add_workflow(self.NoConfigWorkflow())
+        assert model.rowCount() == 2
+
+    @pytest.mark.parametrize(
+        "workflow, expected",
+        [
+            (SpamWorkflow(), True),
+            (NoConfigWorkflow(), False)
+        ]
+    )
+    def test_has_children(self, model, workflow, expected):
+        model.add_workflow(workflow)
+        assert model.hasChildren(model.index(0,0)) is expected
+        assert model.hasChildren(model.index(0,0)) is expected
+    def test_has_children_root(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.hasChildren() is True
+
+    def test_clear(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.rowCount() == 1
+        model.clear()
+        assert model.rowCount() == 0
+
+    @pytest.mark.parametrize(
+        "workflow, expected_display",
+        [
+            (SpamWorkflow(), "Spam"),
+            (NoConfigWorkflow(), "Workflow that has no config options"),
+            (BaconWorkflow(), "Bacon")
+        ]
+    )
+    def test_data_name(self, model, workflow, expected_display):
+        model.add_workflow(workflow)
+        assert model.data(model.index(0, 0)) == expected_display
+
+    def test_data_name_multiple_workflows(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        model.add_workflow(self.NoConfigWorkflow())
+        index = model.index(1, 0)
+        assert model.data(index) == "Workflow that has no config options"
+
+    @pytest.mark.parametrize(
+        "expected_flags, index, parent_index",
+        [
+            (Qt.ItemFlag.ItemIsEnabled, (0, 0), None),
+            (Qt.ItemFlag.ItemIsEnabled, (0, 1), None),
+            (
+                (
+                    Qt.ItemFlag.ItemIsEnabled and
+                    Qt.ItemFlag.ItemIsSelectable and
+                    Qt.ItemFlag.ItemIsEditable
+                ),
+                (0, 1), (0, 0)
+            ),
+            (
+                (
+                    Qt.ItemFlag.ItemIsEnabled and
+                    Qt.ItemFlag.ItemIsSelectable
+                ),
+                (0, 0), (0, 0)
+            )
+        ],
+    )
+    def test_flags(self, model, expected_flags, index, parent_index):
+        model.add_workflow(self.SpamWorkflow())
+        if parent_index is not None:
+            index = model.index(*index, parent=model.index(*parent_index))
+        else:
+            index = model.index(*index)
+        assert expected_flags in model.flags(index)
+
+    @pytest.mark.parametrize(
+        ["section", "orientation", "role", "expected"],
+        [
+            (0, Qt.Orientation.Horizontal, Qt.DisplayRole, 'Property'),
+            (0, Qt.Orientation.Vertical, Qt.DisplayRole, 1),
+            (1, Qt.Orientation.Horizontal, Qt.DisplayRole, 'Value'),
+            (1, Qt.Orientation.Vertical, Qt.DisplayRole, None)
+        ]
+    )
+    def test_header_data(self, model, section, orientation, role, expected):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.headerData(section, orientation, role=role) == expected
+
+    def test_index(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        workflow_index = model.index(0, 0)
+        assert workflow_index.isValid()
+        option_index = model.index(0, 0, parent=workflow_index)
+        assert option_index.isValid()
+        is_parent_valid = option_index.parent().isValid()
+        assert is_parent_valid is True
+
+    def test_set_data_updates_settings(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(0, 1, parent=model.index(0, 0))
+        model.setData(index, "new edits")
+        assert model.data(index) == "new edits"
+
+    def test_set_data_calls_data_changed(self, model, qtbot):
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(0, 1, parent=model.index(0, 0))
+        with qtbot.wait_signal(model.dataChanged):
+            model.setData(index, "new edits")
+
+    @pytest.mark.parametrize(
+        'expected_valid, index, parent_index',
+        [
+            (True,  (0, 1), (0, 0)),
+            (False, (0, 0), (0, 0)),
+            (False, (0, 1), None),
+            (False, (1, 0), None),
+        ]
+    )
+    def test_set_data_valid_indexes(self, model, expected_valid, index, parent_index):
+        model.add_workflow(self.SpamWorkflow())
+        if parent_index is None:
+            index = model.index(*index)
+        else:
+            index = model.index(*index, parent=model.index(*parent_index))
+        assert model.setData(index, "new edits") is expected_valid
+
+    def test_workflows(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert len(model.workflows) == 1
+
+    def test_results(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        index = model.index(0, 1, parent=model.index(0, 0))
+        model.setData(index, "new edits")
+        assert model.results() == {
+            "Spam": {
+                'Dummy config': "new edits"
+            }
+        }
+
+    def test_not_modified_by_default(self, model):
+        assert model.modified() is False
+
+    def test_modified_is_true_if_added_workflow(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        assert model.modified() is True
+
+    def test_not_modified_by_after_reset(self, model):
+        model.add_workflow(self.SpamWorkflow())
+        model.reset_modified()
+        assert model.modified() is False
+
+
+class TestWorkflowSettingsMetadata:
+    @pytest.fixture()
+    def empty_model(self):
+        return models.settings.WorkflowSettingsMetadata()
+
+    def test_child(self, empty_model):
+        child = Mock(name="child_item")
+        model = empty_model
+        model.child_items.append(child)
+        assert model.child(0) == child
+
+    def test_invalid_child(self, empty_model):
+        assert empty_model.child(0) is None
+
+    def test_label(self, empty_model):
+        model = empty_model
+        model.option = workflow.BooleanSelect(label="Spam")
+        assert model.label == "Spam"
+
+    def test_label_is_none_on_when_no_options(self, empty_model):
+        model = empty_model
+        model.option = None
+        assert model.label is None
+
+    def test_insert_child_returns_false(self, empty_model):
+        assert empty_model.insert_children(0, 1, 0) is False
+
+    def test_remove_child_returns_false(self, empty_model):
+        assert empty_model.remove_children(0, 1) is False
+
+    def test_child_number_with_no_parent_is_zero(self, empty_model):
+        assert empty_model.child_number() == 0
+
+    def test_child_number(self):
+        parent_model = models.settings.WorkflowSettingsItemWorkflow()
+        other_item = models.settings.WorkflowSettingsMetadata(parent=parent_model)
+        model = models.settings.WorkflowSettingsMetadata(parent = parent_model)
+        parent_model.child_items.append(other_item)
+        parent_model.child_items.append(model)
+        assert model.child_number() == 1
+
+    def test_last_child(self, empty_model):
+        model = empty_model
+        child_item = Mock(name="child item")
+        model.child_items.append(child_item)
+        assert model.last_child() == child_item
+
+
+class TestWorkflowSettingsRoot:
+    @pytest.fixture()
+    def model(self):
+        return models.settings.WorkflowSettingsRoot()
+
+    def test_insert_children_invalid_index_returns_false(self, model):
+        assert model.insert_children(-1, 0, 0) is False
+
+    def test_data_column_none_data_role(self, model):
+        assert model.data_column(0, QtCore.Qt.ItemDataRole.FontRole) is None
+
+    def test_data_column_invalid_column(self, model):
+        assert model.data_column(-1) is None
+
+    def test_data_column(self, model):
+        assert model.data_column(0) == 'Property'
+
+    def test_flags(self, model):
+        assert \
+            model.flags(Mock(name='index')) == QtCore.Qt.ItemFlag.NoItemFlags
+
+    def test_data_invalid_index_is_none(self, model):
+        assert model.data(-1) is None
+
+    def test_parent(self, model):
+        parent = Mock(name="parent")
+        model.parent_item = parent
+        assert model.parent() == parent
+
+    def test_child_number_defaults_to_zero(self):
+        model = models.settings.WorkflowSettingsRoot()
+        assert model.child_number() == 0
+
+
+class TestWorkflowSettingsItemWorkflow:
+    @pytest.fixture()
+    def model(self):
+        return models.settings.WorkflowSettingsItemWorkflow()
+
+    def test_remove_children(self, model):
+        model.child_items.append(Mock())
+        model.remove_children(0,1)
+        assert len(model.child_items) == 0
+
+    def test_remove_children_when_empty_returns_false(self, model):
+        assert model.remove_children(0,1) is False
 
-    assert model is not None
diff --git a/tests/frontend/test_qt_dialogs.py b/tests/frontend/test_qt_dialogs.py
index d10c98ac7..f3dc1c9eb 100644
--- a/tests/frontend/test_qt_dialogs.py
+++ b/tests/frontend/test_qt_dialogs.py
@@ -1,20 +1,31 @@
 import platform
 import logging
-from unittest.mock import Mock, patch, mock_open
+from unittest.mock import Mock, patch, mock_open, MagicMock, ANY
 import pytest
+
+import speedwagon
+import speedwagon.config
+import sys
+import typing
 QtCore = pytest.importorskip("PySide6.QtCore")
 QtWidgets = pytest.importorskip("PySide6.QtWidgets")
-
 from speedwagon.frontend.qtwidgets.dialog import settings, dialogs
-
+from speedwagon.frontend.qtwidgets import models
+from speedwagon.frontend.qtwidgets.models.tabs import (
+    AbsLoadTabDataModelStrategy,
+    TabDataModelYAMLLoader
+)
+if sys.version_info < (3, 10):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
 
 def test_settings_open_dir_if_location_is_set(qtbot, monkeypatch):
     settings_dialog = settings.SettingsDialog()
     qtbot.addWidget(settings_dialog)
     settings_dialog.settings_location = "some_file_path"
-
     mock_call = Mock()
-    monkeypatch.setattr(settings.OpenSettingsDirectory, "open", mock_call)
+    monkeypatch.setattr(speedwagon.config.OpenSettingsDirectory, "open", mock_call)
     settings_dialog.open_settings_path_button.click()
     assert mock_call.called is True
 
@@ -22,7 +33,7 @@ def test_settings_open_dir_if_location_is_set(qtbot, monkeypatch):
 class TestOpenSettings:
     def test_open_darwin_settings(self, monkeypatch):
         settings_directory = "some/settings/path"
-        opening_strategy = settings.DarwinOpenSettings(settings_directory)
+        opening_strategy = speedwagon.config.DarwinOpenSettings(settings_directory)
         import subprocess
         call = Mock()
         monkeypatch.setattr(subprocess, "call", call)
@@ -41,7 +52,7 @@ def test_open_unsupported_settings(self, qtbot, monkeypatch):
 
     def test_open_windows_settings(self, monkeypatch):
         settings_directory = "some\\settings\\path"
-        opening_strategy = settings.WindowsOpenSettings(settings_directory)
+        opening_strategy = speedwagon.config.WindowsOpenSettings(settings_directory)
         import os
         startfile = Mock()
         if platform.system() != "Windows":
@@ -59,40 +70,68 @@ def test_on_okay_not_modified(self, qtbot, monkeypatch):
         monkeypatch.setattr(QtWidgets.QMessageBox, "exec", mock_exec)
         settings_tab = settings.GlobalSettingsTab()
         qtbot.addWidget(settings_tab)
-        settings_tab.on_okay()
         assert mock_exec.called is False
 
-    @pytest.mark.parametrize("config_file, expect_file_written",
-                             [
-                                 (None, False),
-                                 ("dummy.yml", True)
-                             ])
-    def test_on_okay_modified(self, qtbot, monkeypatch, config_file,
-                              expect_file_written):
-        from PySide6 import QtWidgets
-        from speedwagon.frontend.qtwidgets import models
-        mock_exec = Mock()
-        monkeypatch.setattr(QtWidgets.QMessageBox, "exec", mock_exec)
-        monkeypatch.setattr(models, "serialize_settings_model", Mock())
-        settings_tab = settings.GlobalSettingsTab()
-        qtbot.addWidget(settings_tab)
-        settings_tab.on_modified()
-        settings_tab.config_file = config_file
-        m = mock_open()
-        with patch('builtins.open', m):
-            settings_tab.on_okay()
-        assert m.called is expect_file_written
+    def test_not_modified_from_init(self, qtbot):
+        tab = settings.GlobalSettingsTab()
+        assert tab.data_is_modified() is False
 
+    def test_read_config_data_raises_if_no_config_file(self, qtbot):
+        tab = settings.GlobalSettingsTab()
+        tab.config_file = None
+        with pytest.raises(FileNotFoundError):
+            tab.read_config_data()
 
-class TestTabsConfigurationTab:
-    def test_on_ok_not_modified(self, qtbot, monkeypatch):
-        config_tab = settings.TabsConfigurationTab()
-        from PySide6 import QtWidgets
-        mock_exec = Mock()
-        monkeypatch.setattr(QtWidgets.QMessageBox, "exec", mock_exec)
-        config_tab.on_okay()
-        assert mock_exec.called is False
+    def test_read_config_data_raises_if_config_file_is_invalid(self, monkeypatch, qtbot):
+        tab = settings.GlobalSettingsTab()
+        tab.config_file = "some invalid file"
+        monkeypatch.setattr(settings.os.path, "exists", lambda path: False)
+        with pytest.raises(FileNotFoundError):
+            tab.read_config_data()
 
+
+    def test_read_config_data(self, monkeypatch, mocker, qtbot):
+        tab = settings.GlobalSettingsTab()
+        tab.config_file = "some valid file"
+        monkeypatch.setattr(
+            settings.os.path,
+            "exists",
+            lambda path: path == "some valid file"
+        )
+        settings_model = models.SettingsModel()
+        monkeypatch.setattr(
+            settings,
+            "build_setting_qt_model",
+            lambda config_file: settings_model
+        )
+        table_set_model = mocker.spy(tab.settings_table, "setModel")
+        tab.read_config_data()
+        table_set_model.assert_called_once_with(settings_model)
+
+
+    # @pytest.mark.parametrize("config_file, expect_file_written",
+    #                          [
+    #                              (None, False),
+    #                              ("dummy.yml", True)
+    #                          ])
+    # def test_on_okay_modified(self, qtbot, monkeypatch, config_file,
+    #                           expect_file_written):
+    #     from PySide6 import QtWidgets
+    #     from speedwagon.frontend.qtwidgets import models
+    #     mock_exec = Mock()
+    #     monkeypatch.setattr(QtWidgets.QMessageBox, "exec", mock_exec)
+    #     monkeypatch.setattr(models, "serialize_settings_model", Mock())
+    #     settings_tab = settings.GlobalSettingsTab()
+    #     qtbot.addWidget(settings_tab)
+    #     settings_tab.on_modified()
+    #     settings_tab.config_file = config_file
+    #     m = mock_open()
+    #     with patch('builtins.open', m):
+    #         settings_tab.on_okay()
+    #     assert m.called is expect_file_written
+
+
+class TestTabsConfigurationTab:
     @pytest.mark.parametrize("settings_location, writes_to_file", [
         ("setting_location", True),
         (None, False)
@@ -104,18 +143,88 @@ def test_on_okay_modified(self, qtbot, monkeypatch, settings_location,
 
         config_tab = settings.TabsConfigurationTab()
         config_tab.settings_location = settings_location
-        config_tab.editor.on_modified()
-        from speedwagon.frontend.qtwidgets import tabs
-        write_tabs_yaml = Mock()
+        # from speedwagon.frontend.qtwidgets import tabs
+        from speedwagon import config
+        config_management_strategy = Mock(config.AbsTabsConfigDataManagement, name="AbsTabsConfigDataManagement")
+        # write_tabs_yaml = Mock()
         mock_exec = Mock(name="message box exec")
         with monkeypatch.context() as mp:
-            mp.setattr(tabs, "write_tabs_yaml", write_tabs_yaml)
+
+            mp.setattr(
+                config_tab,
+                "tab_config_management_strategy",
+                Mock(return_value=config_management_strategy)
+            )
+            # mp.setattr(tabs, "write_tabs_yaml", write_tabs_yaml)
             mp.setattr(QtWidgets.QMessageBox, "exec", mock_exec)
             config_tab.on_okay()
 
         assert \
             mock_exec.called is True and \
-            write_tabs_yaml.called is writes_to_file
+            config_management_strategy.save.called is writes_to_file, \
+            f"mock_exec.called is {mock_exec.called}"
+
+    def test_create_new_tab_set_modified_true(self, qtbot, monkeypatch):
+        config_tab = settings.TabsConfigurationTab()
+        with qtbot.wait_signal(config_tab.changes_made):
+            with monkeypatch.context() as mp:
+                mp.setattr(
+                    settings.QtWidgets.QInputDialog,
+                    "getText",
+                    lambda *args, **kwargs: ("new tab", True)
+                )
+                qtbot.mouseClick(
+                    config_tab.editor.new_tab_button,
+                    QtCore.Qt.LeftButton
+                )
+        assert config_tab.data_is_modified() is True
+    def test_revert_changes(self, qtbot, monkeypatch):
+        # Test if the changes made reverted to original start shows data not
+        # modified
+        config_tab = settings.TabsConfigurationTab()
+        with qtbot.wait_signal(config_tab.changes_made):
+            with monkeypatch.context() as mp:
+                mp.setattr(
+                    settings.QtWidgets.QInputDialog,
+                    "getText",
+                    lambda *args, **kwargs: ("new tab", True)
+                )
+                qtbot.mouseClick(
+                    config_tab.editor.new_tab_button,
+                    QtCore.Qt.LeftButton
+                )
+        assert config_tab.data_is_modified() is True
+        config_tab.editor.set_current_tab("new tab")
+        with qtbot.wait_signal(config_tab.changes_made):
+            qtbot.mouseClick(
+                config_tab.editor.delete_current_tab_button,
+                QtCore.Qt.LeftButton
+            )
+        assert config_tab.data_is_modified() is False
+
+    def test_tab_config_management_strategy_no_settings_location_raises(self, qtbot):
+        config_tab = settings.TabsConfigurationTab()
+        with pytest.raises(RuntimeError):
+            config_tab.tab_config_management_strategy()
+
+    def test_tab_config_management_strategy(self, qtbot):
+        config_tab = settings.TabsConfigurationTab()
+        config_tab.settings_location = "dummy.yml"
+        assert isinstance(
+            config_tab.tab_config_management_strategy(),
+            speedwagon.config.AbsTabsConfigDataManagement
+        )
+
+    def test_get_data(self, qtbot):
+        config_tab = settings.TabsConfigurationTab()
+        assert "tab_information" in config_tab.get_data()
+
+    def test_load(self, qtbot):
+        strategy = Mock(AbsLoadTabDataModelStrategy)
+        config_tab = settings.TabsConfigurationTab()
+
+        config_tab.load(strategy)
+        assert strategy.load.called is True
 
 
 class TestTabEditor:
@@ -132,7 +241,7 @@ def test_set_all_workflows_set_model(self, qtbot, editor):
             '': mock_workflow
         }
         editor.all_workflows_list_view.setModel = Mock()
-        editor.set_all_workflows(workflows)
+        editor.set_all_workflows()
         assert editor.all_workflows_list_view.setModel.called is True
 
     def test_create_new_tab(self, qtbot, monkeypatch, editor):
@@ -166,23 +275,12 @@ def test_create_new_tab_cannot_create_same_name_tabs(
             editor
     ):
         qtbot.addWidget(editor)
-        from speedwagon.frontend.qtwidgets import tabs
-        from speedwagon.frontend.qtwidgets import models
-        with monkeypatch.context() as mp:
-            def read_tabs_yaml(*args, **kwargs):
-                return [
-                    tabs.TabData("existing tab", models.WorkflowListModel2())
-                ]
-
-            mp.setattr(tabs, "read_tabs_yaml", read_tabs_yaml)
-            editor.tabs_file = "dummy.yml"
-
         with monkeypatch.context() as mp:
 
             mp.setattr(
                 settings.QtWidgets.QInputDialog,
                 "getText",
-                lambda *args, **kwargs: ("existing tab", True)
+                lambda *args, **kwargs: ("my tab", True)
             )
 
             # Make sure that this can exit
@@ -201,9 +299,28 @@ def read_tabs_yaml(*args, **kwargs):
                 "QMessageBox",
                 Mock(return_value=QMessageBox)
             )
+
+            # Add "my tab" the first time
+            qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+
+            # try to add "my tab" a second time
             qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+
+            # Should bring up an error window
             assert QMessageBox.setWindowTitle.called is True
 
+    def test_add_tab_changes_selection(self, qtbot, monkeypatch, editor):
+        qtbot.addWidget(editor)
+        with monkeypatch.context() as mp:
+            mp.setattr(
+                settings.QtWidgets.QInputDialog,
+                "getText",
+                lambda *args, **kwargs: ("new tab", True)
+            )
+            with qtbot.wait_signal(editor.changes_made):
+                qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+        assert editor.selected_tab_combo_box.currentText() == "new tab"
+
     def test_delete_tab(self, qtbot, monkeypatch, editor):
         qtbot.addWidget(editor)
         with monkeypatch.context() as mp:
@@ -214,53 +331,350 @@ def test_delete_tab(self, qtbot, monkeypatch, editor):
             )
             qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
         assert editor.selected_tab_combo_box.model().rowCount() == 1
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.delete_current_tab_button,
+                QtCore.Qt.LeftButton
+            )
+
+        assert editor.selected_tab_combo_box.model().rowCount() == 0
+
+    def test_delete_everything(self, qtbot, monkeypatch, editor):
+        qtbot.addWidget(editor)
+        with monkeypatch.context() as mp:
+            mp.setattr(
+                settings.QtWidgets.QInputDialog,
+                "getText",
+                lambda *args, **kwargs: ("new tab", True)
+            )
+            qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+        assert editor.selected_tab_combo_box.model().rowCount() == 1
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.delete_current_tab_button,
+                QtCore.Qt.LeftButton
+            )
+
+        assert editor.selected_tab_combo_box.model().rowCount() == 0
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.delete_current_tab_button,
+                QtCore.Qt.LeftButton
+            )
+        a = editor.tab_workflows_list_view.model()
+        print(a.rowCount())
+    def test_modified_by_add(self, qtbot, monkeypatch):
+        editor = settings.TabEditor()
+
+        qtbot.addWidget(editor)
+        def getText(*args, **kwargs):
+            return "new name", True
+        with qtbot.wait_signal(editor.changes_made):
+            with monkeypatch.context() as ctx:
+                ctx.setattr(settings.QtWidgets.QInputDialog, "getText", getText)
+                qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+        assert editor.modified is True
+
+    def test_not_modified_if_deleted_newly_added_tab(
+            self,
+            qtbot,
+            monkeypatch
+    ):
+        editor = settings.TabEditor()
+
+        qtbot.addWidget(editor)
+        def getText(*args, **kwargs):
+            return "new name", True
+
+        with qtbot.wait_signal(editor.changes_made):
+            with monkeypatch.context() as ctx:
+                ctx.setattr(settings.QtWidgets.QInputDialog, "getText", getText)
+                qtbot.mouseClick(editor.new_tab_button, QtCore.Qt.LeftButton)
+        editor.set_current_tab("new name")
+        with qtbot.wait_signal(editor.changes_made):
+            assert editor.modified is True
+            qtbot.mouseClick(editor.delete_current_tab_button, QtCore.Qt.LeftButton)
+        assert editor.modified is False
+
+    def test_workflow_switch_index(self, qtbot):
+        # Check what index is being called when selected tab combobox widget
+        # value for is changed
+        dialog_box = QtWidgets.QDialog()
+        editor = settings.TabEditor(dialog_box)
 
+        class DummyLoader(AbsLoadTabDataModelStrategy):
+            def load(self, model: models.TabsTreeModel) -> None:
+                class Spam1(speedwagon.Workflow):
+                    name = "spam 1"
+
+                class Spam2(speedwagon.Workflow):
+                    name = "spam 2"
+
+                class Spam3(speedwagon.Workflow):
+                    name = "spam 3"
+
+                model.append_workflow_tab("All", [Spam1, Spam2, Spam3])
+                model.append_workflow_tab("Bacon", [Spam2])
+                model.append_workflow_tab("Eggs", [Spam1])
+
+        loader = DummyLoader()
+        editor.all_workflows_list_view.setModel(editor._all_workflows_model)
+        loader.load(editor.model)
+        # editor._user_tabs_model.set_active("Bacon", True)
+        # editor._user_tabs_model.set_active("Eggs", True)
+        editor.selected_tab_combo_box.setCurrentIndex(0)
+        with qtbot.wait_signal(editor.current_tab_index_changed) as e:
+            editor.set_current_tab("Eggs")
+        assert editor.model.data(editor.model.index(e.args[0].row())) == "Eggs"
+
+    def test_add_workflow(self, qtbot):
+        editor = settings.TabEditor()
+        class DummyLoader(AbsLoadTabDataModelStrategy):
+            def load(self, model: models.TabsTreeModel) -> None:
+                class Spam1(speedwagon.Workflow):
+                    name = "spam 1"
+
+                class Spam2(speedwagon.Workflow):
+                    name = "spam 2"
+
+                class Spam3(speedwagon.Workflow):
+                    name = "spam 3"
+
+                class Spam4(speedwagon.Workflow):
+                    name = "spam 4"
+
+                model.append_workflow_tab("All", [Spam1, Spam2, Spam3, Spam4])
+                model.append_workflow_tab("Bacon", [Spam2])
+                model.append_workflow_tab("Eggs", [Spam1])
+
+        loader = DummyLoader()
+        loader.load(editor.model)
+        assert editor.model.rowCount() == 3
+        assert editor.model.rowCount(parent=editor.model.index(0)) == 4
+        editor.set_current_tab("Eggs")
+
+        mo = editor.all_workflows_list_view.model()
         qtbot.mouseClick(
-            editor.delete_current_tab_button,
-            QtCore.Qt.LeftButton
+            editor.all_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.all_workflows_list_view.visualRect(mo.index(1,0)).center()
         )
+        assert len(editor.all_workflows_list_view.selectedIndexes()) == 1
+        assert editor.tab_workflows_list_view.model().rowCount() == 1
+        qtbot.mouseClick(
+            editor.add_items_button,
+            QtCore.Qt.LeftButton,
+        )
+        assert editor.tab_workflows_list_view.model().rowCount() == 2
 
-        assert editor.selected_tab_combo_box.model().rowCount() == 0
+    def test_workflow_emits_changes(self, qtbot):
+        editor = settings.TabEditor()
 
+        class DummyLoader(AbsLoadTabDataModelStrategy):
+            def load(self, model: models.TabsTreeModel) -> None:
+                class Spam1(speedwagon.Workflow):
+                    name = "spam 1"
 
-class TestSettingsBuilder:
-    @pytest.fixture()
-    def nothing_set_settings(self, qtbot):
-        builder = settings.SettingsBuilder()
-        return builder.build()
+                class Spam2(speedwagon.Workflow):
+                    name = "spam 2"
 
-    def test_nothing_has_no_tabs(self, nothing_set_settings):
-        assert nothing_set_settings.tabs_widget.count() == 0
+                class Spam3(speedwagon.Workflow):
+                    name = "spam 3"
 
-    def test_nothing_no_settings_directory_button(self, nothing_set_settings):
-        assert \
-            nothing_set_settings.open_settings_path_button.isHidden() is True
+                class Spam4(speedwagon.Workflow):
+                    name = "spam 4"
 
-    def test_add_global_settings(self, qtbot, monkeypatch):
-        builder = settings.SettingsBuilder()
-        builder.add_global_settings("something.ini")
-        read_config_data = Mock()
+                model.append_workflow_tab("All", [Spam1, Spam2, Spam3, Spam4])
+                model.append_workflow_tab("Bacon", [Spam2])
+                model.append_workflow_tab("Eggs", [Spam1])
 
-        monkeypatch.setattr(
-            settings.GlobalSettingsTab,
-            "read_config_data",
-            read_config_data
+        loader = DummyLoader()
+        loader.load(editor.model)
+        assert editor.model.rowCount() == 3
+        assert editor.model.rowCount(parent=editor.model.index(0)) == 4
+        # editor.set_tab_visibility("Bacon", True)
+        # editor.set_tab_visibility("Eggs", True)
+        editor.set_current_tab("Eggs")
+
+        qtbot.mouseClick(
+            editor.all_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.all_workflows_list_view.visualRect(
+                editor.all_workflows_list_view.model().index(1,0)
+            ).center()
+        )
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.add_items_button,
+                QtCore.Qt.LeftButton,
+            )
+        editor.model.reset_modified()
+        assert editor.modified is False
+
+        qtbot.mouseClick(
+            editor.tab_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.tab_workflows_list_view.visualRect(
+                editor.tab_workflows_list_view.model().index(1,0)
+            ).center()
         )
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.remove_items_button,
+                QtCore.Qt.LeftButton,
+            )
+        assert editor.modified is True
+
+    def test_workflow_revert_no_modified(self, qtbot):
+        dialog = QtWidgets.QDialog()
+        editor = settings.TabEditor()
+        editor.setParent(dialog)
+        class DummyLoader(AbsLoadTabDataModelStrategy):
+            def load(self, model: models.TabsTreeModel) -> None:
+                class Spam1(speedwagon.Workflow):
+                    name = "spam 1"
+
+                class Spam2(speedwagon.Workflow):
+                    name = "spam 2"
+
+                class Spam3(speedwagon.Workflow):
+                    name = "spam 3"
 
+                class Spam4(speedwagon.Workflow):
+                    name = "spam 4"
+
+                model.append_workflow_tab("All", [Spam1, Spam2, Spam3, Spam4])
+                model.append_workflow_tab("Bacon", [Spam2])
+                model.append_workflow_tab("Eggs", [Spam1])
+                model.reset_modified()
+
+        loader = DummyLoader()
+        loader.load(editor.model)
+        assert editor.model.rowCount() == 3
+        assert editor.model.rowCount(parent=editor.model.index(0)) == 4
+        # editor.set_tab_visibility("Bacon", True)
+        # editor.set_tab_visibility("Eggs", True)
+        editor.set_current_tab("Eggs")
+
+        qtbot.mouseClick(
+            editor.all_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.all_workflows_list_view.visualRect(
+                editor.all_workflows_list_view.model().index(1,0)
+            ).center()
+        )
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.add_items_button,
+                QtCore.Qt.LeftButton,
+            )
+        assert editor.modified is True
+
+        qtbot.mouseClick(
+            editor.tab_workflows_list_view.viewport(),
+            QtCore.Qt.LeftButton,
+            pos=editor.tab_workflows_list_view.visualRect(
+                editor.tab_workflows_list_view.model().index(1,0)
+            ).center()
+        )
+        with qtbot.wait_signal(editor.changes_made):
+            qtbot.mouseClick(
+                editor.remove_items_button,
+                QtCore.Qt.LeftButton,
+            )
+        assert editor.modified is False
+
+
+class TestSettingsBuilder2:
+    def test_accepted(self, qtbot):
+        builder = settings.SettingsBuilder2()
         dialog = builder.build()
-        assert dialog.tabs_widget.count() == 1
+        ok = dialog.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok.setEnabled(True)
+        qtbot.add_widget(dialog)
+        with qtbot.wait_signal(dialog.accepted):
+            qtbot.mouseClick(ok, QtCore.Qt.LeftButton)
 
-    def test_add_editor_tab(self, qtbot, monkeypatch):
-        builder = settings.SettingsBuilder()
-        builder.add_tabs_setting("something")
+    def test_rejected(self, qtbot):
+        builder = settings.SettingsBuilder2()
+        dialog = builder.build()
+        cancel_button = dialog.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Cancel
+        )
+        qtbot.add_widget(dialog)
+        with qtbot.wait_signal(dialog.rejected):
+            qtbot.mouseClick(cancel_button, QtCore.Qt.LeftButton)
+
+    def test_add_tab_increases_tabs(self, qtbot):
+        builder = settings.SettingsBuilder2()
+        item = settings.TabsConfigurationTab()
+        builder.add_tab("spam", item)
         dialog = builder.build()
         assert dialog.tabs_widget.count() == 1
 
-    def test_settings_path(self, qtbot):
-        builder = settings.SettingsBuilder()
-        builder.add_open_settings("some_directory")
+    def test_save_callback_called(self, qtbot):
+        builder = settings.SettingsBuilder2()
+        on_save_callback = Mock()
+        builder.add_on_save_callback(on_save_callback)
+        item = settings.TabsConfigurationTab()
+        builder.add_tab("spam", item)
+        dialog = builder.build()
+        ok = dialog.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok.setEnabled(True)
+        qtbot.add_widget(dialog)
+        with qtbot.wait_signal(dialog.accepted):
+            qtbot.mouseClick(ok, QtCore.Qt.LeftButton)
+        assert on_save_callback.called is True
+
+    def test_add_on_save_callback_returns_same_widgets(self, qtbot):
+
+        builder = settings.SettingsBuilder2()
+        item = settings.TabsConfigurationTab()
+        builder.add_tab("spam", item)
+
+        def my_callback(parent, tab_widgets: typing.Dict[str, settings.SettingsTab]):
+            assert tab_widgets['spam'] == item
+
+        builder.add_on_save_callback(my_callback)
         dialog = builder.build()
-        assert dialog.open_settings_path_button.isHidden() is False
+        ok = dialog.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok.setEnabled(True)
+        qtbot.add_widget(dialog)
+        with qtbot.wait_signal(dialog.accepted):
+            qtbot.mouseClick(ok, QtCore.Qt.LeftButton)
+
+    @pytest.mark.parametrize(
+        "settings_widget",
+        [
+            settings.TabsConfigurationTab,
+            settings.GlobalSettingsTab,
+            settings.PluginsTab
+        ]
+    )
+    def test_get_data(self, qtbot, settings_widget):
+
+        builder = settings.SettingsBuilder2()
+        item = settings_widget()
+        builder.add_tab("spam", item)
+
+        def my_callback(parent, tab_widgets: typing.Dict[str, settings.SettingsTab]):
+            assert isinstance(tab_widgets['spam'].get_data(), dict)
+
+        builder.add_on_save_callback(my_callback)
+        dialog = builder.build()
+        ok = dialog.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        )
+        ok.setEnabled(True)
+        qtbot.add_widget(dialog)
+        with qtbot.wait_signal(dialog.accepted):
+            qtbot.mouseClick(ok, QtCore.Qt.LeftButton)
 
 
 class TestWorkflowProgress:
@@ -387,3 +801,385 @@ def test_warnings(self, state_class, command):
             state = state_class(context=Mock())
             getattr(state, command)()
         assert len(record) > 0
+
+
+class TestConfigSaver:
+
+    @pytest.fixture()
+    def sample_settings(self):
+        return {
+            "spam": "bacon",
+            "eggs": "lovely spam"
+        }
+
+    @pytest.fixture()
+    def settings_model(self, sample_settings):
+        new_model = models.SettingsModel()
+        for key, value in sample_settings.items():
+            new_model.add_setting(key, value)
+        return new_model
+
+    def test_set_config_path(
+            self,
+            qtbot,
+            monkeypatch,
+            sample_settings,
+            settings_model
+    ):
+        plugins_tab = settings.PluginsTab()
+        entry_point = Mock(module="bar")
+        entry_point.name = "foo"
+        plugins_tab.plugins_activation.model.add_entry_point(
+            entry_point,
+            enabled=True
+        )
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        saver.config_file_path = "some/file/path.ini"
+        global_settings = settings.GlobalSettingsTab()
+        global_settings.model = settings_model
+        CustomTabsYamlConfig = Mock(name="CustomTabsYamlConfig")
+        CustomTabsYamlConfig.save = Mock(name='save')
+        monkeypatch.setattr(saver, "get_tab_config_strategy", Mock(return_value=CustomTabsYamlConfig))
+
+        with patch('builtins.open', mock_open()) as mocked_file:
+                saver.save({
+                    "Global Settings": global_settings,
+                    "Tabs": settings.TabsConfigurationTab(),
+                    "Plugins": settings.PluginsTab()
+                })
+                mocked_file.assert_called_once()
+
+    def test_config_file_path_none(self, qtbot):
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        assert saver.config_file_path is None
+
+    def test_config_file_path_set(self, qtbot):
+        # This test exists because ConfigSaver.config_file_path passes this
+        # variable to a file manager object
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        saver.config_file_path = "dummy.ini"
+        assert saver.config_file_path == "dummy.ini"
+
+    def test_on_success_callbacks(self, qtbot):
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        success_callback = Mock()
+        saver.add_success_call_back(success_callback)
+        saver.on_success()
+        assert success_callback.called is True
+
+    def test_get_tab_config_strategy_without_tabs_yaml_path_raises(self, qtbot):
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        with pytest.raises(RuntimeError):
+            saver.get_tab_config_strategy()
+
+    def test_get_tab_config_strategy(self, qtbot):
+        saver = settings.ConfigSaver(QtWidgets.QWidget())
+        saver.tabs_yaml_path = "dummy.yml"
+        assert isinstance(
+            saver.get_tab_config_strategy(),
+            speedwagon.config.AbsTabsConfigDataManagement
+        )
+
+    def test_foo(self):
+        saver = settings.ConfigSaver()
+        saver.tabs_yaml_path = "dummy.yml"
+
+
+    # def test_write_yaml(self, qtbot, monkeypatch):
+    #     saver = settings.ConfigSaver(QtWidgets.QWidget())
+    #     CustomTabsYamlConfig = Mock(name="CustomTabsYamlConfig")
+    #     CustomTabsYamlConfig.save = Mock(name='save')
+    #     monkeypatch.setattr(saver, "get_tab_config_strategy", Mock(return_value=CustomTabsYamlConfig))
+    #     tab_config = settings.TabsConfigurationTab()
+    #     tabs_model = models.TabsTreeModel()
+    #
+    #     class FakeWorkflow(speedwagon.Workflow):
+    #         name = "Fake workflow"
+    #
+    #     tabs_model.append_workflow_tab("My Workflow", workflows=[FakeWorkflow])
+    #     tab_config.editor.model = tabs_model
+    #
+    #     saver.save({
+    #         "Global Settings": settings.GlobalSettingsTab(),
+    #         "Tabs": tab_config,
+    #         "Plugins": settings.PluginsTab()
+    #     })
+    #     CustomTabsYamlConfig.save.assert_called_with(
+    #         [
+    #             speedwagon.config.CustomTabData(
+    #                 tab_name="My Workflow", workflow_names=['Fake workflow']
+    #             )
+    #         ]
+    #     )
+    #
+
+class TestConfigFileSave:
+    def test_uses_AbsSaveStrategy(self, qtbot):
+        save_strategy = Mock(
+            settings.AbsSaveStrategy,
+            serialize_data=Mock(return_value="some data")
+        )
+        save_strategy.write_file = Mock()
+        saver = settings.ConfigFileSaver(save_strategy, "some_file.txt")
+        saver.save()
+        save_strategy.write_file.assert_called_once_with(
+            "some data",
+            "some_file.txt"
+        )
+
+
+def test_multisaver_child_save_methods_called():
+    saver = settings.MultiSaver()
+    dummy_saver = Mock(settings.AbsConfigSaver2)
+    saver.config_savers.append(dummy_saver)
+    saver.save()
+    dummy_saver.save.assert_called_once()
+
+
+class TestSettingsTabSaveStrategy:
+
+    def test_serialize_data(self, qtbot):
+        global_settings_tab = Mock(settings.SettingsTab)
+        def serialization_function(_):
+            return 'my serialized data'
+        save_strategy = settings.SettingsTabSaveStrategy(
+            global_settings_tab,
+            serialization_function
+        )
+        assert save_strategy.serialize_data() == 'my serialized data'
+
+
+class TestSettingsTab:
+    def test_data_is_modified_raises_not_implemented(self, qtbot):
+        new_tab = settings.SettingsTab()
+        with pytest.raises(NotImplementedError):
+            new_tab.data_is_modified()
+
+    def test_get_data_defaults_to_empty_dict(self, qtbot):
+        new_tab = settings.SettingsTab()
+        assert new_tab.get_data() == {}
+class TestPluginsTab:
+    def test_not_modified_from_init(self, qtbot):
+        tab = settings.PluginsTab()
+        assert tab.data_is_modified() is False
+
+    def test_load(self, qtbot, mocker, monkeypatch):
+        data = """
+[PLUGINS.mysampleplugin]
+myworkflow = True
+"""
+        with patch(
+                'configparser.open',
+                mock_open(read_data=data)
+        ):
+            config_file = "config.ini"
+            tab = settings.PluginsTab()
+            entry_point = Mock(
+                metadata.EntryPoint,
+                name="EntryPoint",
+                module="mysampleplugin"
+            )
+            entry_point.name = "myworkflow"
+            add_entry_point = mocker.spy(
+                tab.plugins_activation.model,
+                "add_entry_point"
+            )
+            with monkeypatch.context() as mp:
+                mp.setattr(
+                    settings.metadata,
+                    "entry_points",
+                    lambda *_, **__: [entry_point]
+                )
+                tab.load(config_file)
+            add_entry_point.assert_called_once_with(entry_point, True)
+
+
+class TestEntrypointsPluginModelLoader:
+    def test_load_plugins_into_model(self, monkeypatch):
+        sample_ini_file = "config.ini"
+        model = models.PluginActivationModel()
+        assert model.rowCount() == 0
+        loader_strategy = \
+            settings.EntrypointsPluginModelLoader(sample_ini_file)
+
+        monkeypatch.setattr(
+            loader_strategy,
+            "plugin_entry_points",
+            Mock(return_value=[
+                Mock()
+            ])
+        )
+        read_settings_file_plugins = MagicMock()
+        monkeypatch.setattr(
+            speedwagon.config,
+            "read_settings_file_plugins",
+            read_settings_file_plugins
+        )
+        loader_strategy.load_plugins_into_model(model)
+        assert model.rowCount() > 0
+
+
+class TestSettingsDialog:
+    def test_okay_button_active_only_when_modified(self, qtbot):
+        dialog_box = settings.SettingsDialog()
+
+        dialog_tab = settings.GlobalSettingsTab()
+        dialog_tab.model.add_setting("spam", "yes")
+
+        dialog_box.add_tab(dialog_tab, "dummy")
+
+        ok_enabled_before_modified = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+
+        with qtbot.wait_signal(dialog_tab.changes_made):
+            dialog_tab.model.setData(dialog_tab.model.index(0, 1), "no")
+
+        ok_enabled_after_modified = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+
+        assert all(
+            (
+                ok_enabled_before_modified is False,
+                ok_enabled_after_modified is True,
+            )
+        )
+
+    def test_revert_new_tab_plugins_state_buttons(self, qtbot, monkeypatch):
+        dialog_box = settings.SettingsDialog()
+
+        plugin_tab = settings.TabsConfigurationTab()
+        dialog_box.add_tab(plugin_tab, "Plugins")
+        ok_enabled_before_modified = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+        with qtbot.wait_signal(dialog_box.changes_made):
+            with monkeypatch.context() as mp:
+                mp.setattr(
+                    settings.QtWidgets.QInputDialog,
+                    "getText",
+                    lambda *args, **kwargs: ("new tab", True)
+                )
+                qtbot.mouseClick(
+                    plugin_tab.editor.new_tab_button,
+                    QtCore.Qt.LeftButton
+                )
+        ok_enabled_after_modified = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+        with qtbot.wait_signal(dialog_box.changes_made):
+            qtbot.mouseClick(
+                plugin_tab.editor.delete_current_tab_button,
+                QtCore.Qt.LeftButton
+            )
+        ok_enabled_after_reverted = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+
+        expected = {
+            "Ok button enabled initially": False,
+            "Ok button enabled after modifying": True,
+            "Ok button enabled after reverting": False
+        }
+
+        actual = {
+            "Ok button enabled initially": ok_enabled_before_modified,
+            "Ok button enabled after modifying": ok_enabled_after_modified,
+            "Ok button enabled after reverting": ok_enabled_after_reverted
+        }
+        assert expected == actual
+    def test_revert_add_workflows_plugins_state_buttons(self, qtbot, monkeypatch):
+        dialog_box = settings.SettingsDialog()
+
+        plugin_tab = settings.TabsConfigurationTab()
+        dialog_box.add_tab(plugin_tab, "Plugins")
+        with qtbot.wait_signal(dialog_box.changes_made):
+            with monkeypatch.context() as mp:
+                mp.setattr(
+                    settings.QtWidgets.QInputDialog,
+                    "getText",
+                    lambda *args, **kwargs: ("new tab", True)
+                )
+                qtbot.mouseClick(
+                    plugin_tab.editor.new_tab_button,
+                    QtCore.Qt.LeftButton
+                )
+        with qtbot.wait_signal(plugin_tab.editor.model.dataChanged):
+            plugin_tab.editor.model.reset_modified()
+        ok_enabled_before_modified = dialog_box.button_box.button(
+            QtWidgets.QDialogButtonBox.StandardButton.Ok
+        ).isEnabled()
+        # ok_enabled_after_modified = dialog_box.button_box.button(
+        #     QtWidgets.QDialogButtonBox.StandardButton.Ok
+        # ).isEnabled()
+        # with qtbot.wait_signal(dialog_box.changes_made):
+        #     qtbot.mouseClick(
+        #         plugin_tab.editor.delete_current_tab_button,
+        #         QtCore.Qt.LeftButton
+        #     )
+        # ok_enabled_after_reverted = dialog_box.button_box.button(
+        #     QtWidgets.QDialogButtonBox.StandardButton.Ok
+        # ).isEnabled()
+
+        assert all(
+            (
+                ok_enabled_before_modified is False,
+                # ok_enabled_after_modified is True,
+                # ok_enabled_after_reverted is False,
+            )
+        )
+    # dialog_box.exec()
+
+class TestTabDataModelYAMLLoader:
+    def test_prep_data(self):
+        loader = TabDataModelYAMLLoader()
+        class DummyStrategy(speedwagon.config.AbsTabsConfigDataManagement):
+            def data(self):
+                return [
+                    Mock(workflow_names=["spam"])
+                ]
+            def save(self, tabs):
+                pass
+
+        assert "All" in loader.prep_data(DummyStrategy())
+
+    def test_with_no_file_is_no_op(self, qtbot):
+        loader = TabDataModelYAMLLoader()
+        tab_widget = settings.TabEditor()
+        loader.load(tab_widget)
+        loader.yml_file = None
+
+        before_row_count = tab_widget.model.rowCount()
+        loader.load(tab_widget)
+        after_load_row_count = tab_widget.model.rowCount()
+        expected = {
+            "before loading model row count": 0,
+            "after loading model row count": 0
+        }
+        actual = {
+            "before loading model row count": before_row_count,
+            "after loading model row count": after_load_row_count
+        }
+        assert expected == actual
+
+    def test_load(self, qtbot):
+        class BaconWorkflow(speedwagon.Workflow):
+            name = "Bacon"
+        loader = TabDataModelYAMLLoader()
+        loader.prep_data = Mock(return_value={'Spam': [BaconWorkflow]})
+        model = models.TabsTreeModel()
+        loader.yml_file = "dummy.yml"
+
+        before_row_count = model.rowCount()
+        loader.load(model)
+        after_load_row_count = model.rowCount()
+        expected = {
+            "before loading model row count": 0,
+            "after loading model row count": 1
+        }
+        actual = {
+            "before loading model row count": before_row_count,
+            "after loading model row count": after_load_row_count
+        }
+        assert actual == expected
\ No newline at end of file
diff --git a/tests/frontend/test_qt_runners.py b/tests/frontend/test_qt_runners.py
deleted file mode 100644
index 6580c73b1..000000000
--- a/tests/frontend/test_qt_runners.py
+++ /dev/null
@@ -1,204 +0,0 @@
-from unittest.mock import Mock, MagicMock
-
-import pytest
-
-
-import speedwagon
-QtWidgets = pytest.importorskip("PySide6.QtWidgets")
-
-
-class TestQtDialogProgress:
-    def test_initialized(self, qtbot):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-
-        assert dialog_box.dialog.value() == 0 and \
-               dialog_box.dialog.maximum() == 0
-
-    def test_total_tasks_amount_affects_dialog(self, qtbot):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-        dialog_box.total_tasks_amount = 10
-        assert dialog_box.dialog.maximum() == 10 and \
-               dialog_box.total_tasks_amount == 10
-
-    def test_current_tasks_progress_affects_dialog(self, qtbot):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-        dialog_box.total_tasks_amount = 10
-        dialog_box.current_task_progress = 5
-        assert dialog_box.dialog.value() == 5 and \
-               dialog_box.current_task_progress == 5
-
-    def test_title_affects_dialog(self, qtbot):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-        dialog_box.title = "spam"
-        assert dialog_box.dialog.windowTitle() == "spam" and \
-               dialog_box.title == "spam"
-
-    def test_details_affects_dialog(self, qtbot):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-        dialog_box.details = "spam"
-        assert dialog_box.dialog.labelText() == "spam" and \
-               dialog_box.details == "spam"
-
-    @pytest.mark.parametrize(
-        "task_scheduler",
-        [
-            None,
-            Mock(
-                total_tasks=2,
-                current_task_progress=1
-            )
-        ]
-    )
-    def test_refresh_calls_process_events(
-            self, qtbot, task_scheduler, monkeypatch):
-        from speedwagon.frontend.qtwidgets.runners import QtDialogProgress
-        dialog_box = QtDialogProgress()
-        dialog_box.task_scheduler = task_scheduler
-        processEvents = Mock()
-
-        with monkeypatch.context() as mp:
-
-            mp.setattr(
-                QtWidgets.QApplication,
-                "processEvents",
-                processEvents
-            )
-
-            dialog_box.refresh()
-
-        assert processEvents.called is True
-
-
-class TestQtRunner:
-    def test_run_abstract_workflow_calls_run_abs_workflow(self, qtbot):
-        runner = speedwagon.frontend.qtwidgets.runners.QtRunner(None)
-        job = Mock()
-        job.__class__ = speedwagon.job.Workflow
-        runner.run_abs_workflow = Mock()
-        runner.run(
-            job=job,
-            options={}
-        )
-
-        assert runner.run_abs_workflow.called is True
-
-    def test_run_non_abstract_workflow_doesnt_call_run_abs_workflow(
-            self, qtbot):
-
-        runner = speedwagon.frontend.qtwidgets.runners.QtRunner(None)
-        job = Mock()
-        # NOTE: job.__class__ != speedwagon.job.AbsWorkflow
-        runner.run_abs_workflow = Mock()
-        runner.run(
-            job=job,
-            options={}
-        )
-
-        assert runner.run_abs_workflow.called is False
-
-    def test_run_abs_workflow_calls_task_runner(self):
-        manager = Mock()
-        runner = speedwagon.frontend.qtwidgets.runners.QtRunner(manager)
-        job = Mock()
-        job.__class__ = speedwagon.job.AbsWorkflow
-
-        task_runner = MagicMock()
-
-        runner.run_abs_workflow(
-            task_scheduler=task_runner,
-            job=job,
-            options={}
-        )
-        assert task_runner.run.called is True
-
-    def test_run_abs_workflow_fails_with_task_failed_exception(self):
-        manager = Mock()
-        runner = speedwagon.frontend.qtwidgets.runners.QtRunner(manager)
-        job = Mock()
-        job.__class__ = speedwagon.job.AbsWorkflow
-
-        task_runner = MagicMock()
-
-        task_runner.run = Mock(
-            side_effect=speedwagon.frontend.qtwidgets.runners.TaskFailed(
-                "my bad"
-            )
-        )
-        with pytest.raises(
-                speedwagon.frontend.qtwidgets.runners.TaskFailed
-        ) as error:
-            runner.run_abs_workflow(
-                task_scheduler=task_runner,
-                job=job,
-                options={},
-            )
-
-        assert "my bad" in str(error.value)
-
-    def test_update_progress(self):
-        runner = Mock()
-
-        speedwagon.frontend.qtwidgets.runners.QtRunner.update_progress(
-            runner=runner,
-            current=3,
-            total=10
-        )
-        runner.dialog.setMaximum.assert_called_with(10)
-        runner.dialog.setValue.assert_called_with(3)
-
-    def test_update_progress_accepted_on_finish(self):
-        runner = Mock()
-
-        speedwagon.frontend.qtwidgets.runners.QtRunner.update_progress(
-            runner=runner,
-            current=10,
-            total=10
-        )
-        assert runner.dialog.accept.called is True
-
-    def test_update_progress_no_dialog(self):
-        runner = Mock()
-        runner.dialog = None
-        speedwagon.frontend.qtwidgets.runners.QtRunner.update_progress(
-            runner=runner,
-            current=3,
-            total=10
-        )
-
-
-class TestWorkRunnerExternal3:
-    @pytest.mark.filterwarnings(
-        "ignore:Don't use the dialog:DeprecationWarning")
-    def test_abort_calls_callback(self, qtbot):
-        with speedwagon.frontend.qtwidgets.runners.WorkRunnerExternal3(
-                QtWidgets.QWidget()
-        ) as r:
-            r.abort_callback = Mock()
-            r.abort()
-        assert r.abort_callback.called is True
-
-    @pytest.mark.filterwarnings(
-        "ignore:Don't use the dialog:DeprecationWarning")
-    def test_abort_worth_with_no_callback(self, qtbot):
-        with speedwagon.frontend.qtwidgets.runners.WorkRunnerExternal3(
-                QtWidgets.QWidget()
-        ) as r:
-            r.abort_callback = None
-            r.abort()
-
-    @pytest.mark.filterwarnings(
-        "ignore:Don't use the dialog:DeprecationWarning")
-    def test_someone_resetting_dialog_throws_error(self, qtbot):
-        with pytest.raises(AttributeError) as e:
-            work_runner = \
-                speedwagon.frontend.qtwidgets.runners.WorkRunnerExternal3(
-                    QtWidgets.QWidget()
-                )
-            with work_runner as r:
-                r.dialog = None
-        assert "dialog" in str(e.value)
diff --git a/tests/frontend/test_qt_worker.py b/tests/frontend/test_qt_worker.py
index 6a1a6c0bb..9d27072bb 100644
--- a/tests/frontend/test_qt_worker.py
+++ b/tests/frontend/test_qt_worker.py
@@ -5,22 +5,6 @@
 worker = pytest.importorskip("speedwagon.frontend.qtwidgets.worker")
 dialog = pytest.importorskip("speedwagon.frontend.qtwidgets.dialog")
 
-# from speedwagon.frontend.qtwidgets import worker
-# from speedwagon.frontend.qtwidgets import dialog
-
-
-class TestToolJobManager:
-    def test_abort_calls_show(self, qtbot, monkeypatch):
-        work_progress_bar = Mock(spec=dialog.WorkProgressBar)
-        manager = worker.ToolJobManager()
-        monkeypatch.setattr(
-            dialog,
-            "WorkProgressBar",
-            Mock(return_value=work_progress_bar)
-        )
-        manager.abort()
-        assert work_progress_bar.show.called is True
-
 
 @pytest.mark.filterwarnings("ignore::DeprecationWarning")
 class TestProcessWorker:
diff --git a/tests/frontend/test_qtwidgets.py b/tests/frontend/test_qtwidgets.py
index 2d83074f0..081ad974f 100644
--- a/tests/frontend/test_qtwidgets.py
+++ b/tests/frontend/test_qtwidgets.py
@@ -2,14 +2,14 @@
 from unittest.mock import MagicMock, Mock
 
 import pytest
+
 QtWidgets = pytest.importorskip("PySide6.QtWidgets")
 QtCore = pytest.importorskip("PySide6.QtCore")
 from uiucprescon.packager.common import Metadata as PackageMetadata
-# from PySide6 import QtWidgets, QtCore
-
 import speedwagon
 import speedwagon.exceptions
 from speedwagon.frontend import qtwidgets, interaction
+from speedwagon.frontend.qtwidgets import widgets, models
 from speedwagon.frontend.qtwidgets.dialog import title_page_selection
 
 
@@ -335,3 +335,190 @@ def test_get_additional_info_opens_dialog():
         pretask_results=[MagicMock()]
     )
     assert user_request_factory.package_title_page_selection.called is True
+
+
+class TestSelectWorkflow:
+    def test_add_workflow_adds_to_model(self, qtbot):
+        parent = QtWidgets.QWidget()
+        selector = widgets.SelectWorkflow(parent)
+        qtbot.addWidget(selector)
+        class FakeWorkflow(speedwagon.Workflow):
+            pass
+        assert selector.model.rowCount() == 0
+        selector.add_workflow(FakeWorkflow)
+        assert selector.model.rowCount() == 1
+
+    def test_set_current_by_name(self, qtbot):
+        parent = QtWidgets.QWidget()
+        selector = widgets.SelectWorkflow(parent)
+        qtbot.addWidget(selector)
+
+        class FakeWorkflow(speedwagon.Workflow):
+            name = "dummy"
+
+        assert selector.model.rowCount() == 0
+        selector.add_workflow(FakeWorkflow)
+        selector.set_current_by_name("dummy")
+        assert selector.get_current_workflow_type() == FakeWorkflow
+
+    def test_set_current_by_name_invalid_raises(self, qtbot):
+        parent = QtWidgets.QWidget()
+        selector = widgets.SelectWorkflow(parent)
+        qtbot.addWidget(selector)
+
+        class FakeWorkflow(speedwagon.Workflow):
+            name = "dummy"
+
+        assert selector.workflowSelectionView.model().rowCount() == 0
+        selector.add_workflow(FakeWorkflow)
+        with pytest.raises(ValueError):
+            selector.set_current_by_name("invalid workflow")
+    def test_model(self, qtbot):
+        selector = widgets.SelectWorkflow()
+        model = models.WorkflowList()
+        class FakeWorkflow(speedwagon.Workflow):
+            name = "dummy"
+        model.add_workflow(FakeWorkflow)
+        selector.model = model
+        assert selector.workflowSelectionView.model().rowCount() == 1
+
+    def test_selected_index_changed(self, qtbot):
+        class BaconWorkflow(speedwagon.Workflow):
+            name = "Bacon"
+
+        class SpamWorkflow(speedwagon.Workflow):
+            name = "Spam"
+
+        selector = widgets.SelectWorkflow()
+        model = models.WorkflowList()
+        model.add_workflow(BaconWorkflow)
+        model.add_workflow(SpamWorkflow)
+        selector.model = model
+        index = selector.model.index(0, 0)
+        with qtbot.wait_signal(selector.selected_index_changed) as signal:
+            qtbot.mouseClick(
+                selector.workflowSelectionView.viewport(),
+                QtCore.Qt.LeftButton,
+                pos=selector.workflowSelectionView.visualRect(
+                    index
+                ).center()
+            )
+        assert signal.args[0] == index
+
+
+class TestWorkflowsTab3:
+    def test_set_current_workflow_settings_before_workflow_raises(
+            self,
+            qtbot
+    ):
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        with pytest.raises(ValueError):
+            tab.set_current_workflow_settings({"does not exists": True})
+
+    def test_add_workflows(self, qtbot):
+        class Spam(speedwagon.Workflow):
+            name = "spam"
+            def discover_task_metadata(self, *args, **kwargs):
+                return []
+
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        assert len(tab.workflows) == 0
+
+        tab.add_workflow(Spam)
+        assert "spam" in tab.workflows
+        # tab.workflows = {"spam": Spam}
+        assert tab.workflows["spam"] == Spam
+
+    def test_set_current_workflow(self, qtbot):
+        class Spam(speedwagon.Workflow):
+            name = "spam"
+
+            def __init__(self, *args, **kwargs) -> None:
+                super().__init__(*args, **kwargs)
+                assert kwargs['global_settings']['spam'] == "eggs"
+
+            def discover_task_metadata(self, *args, **kwargs):
+                return []
+
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        tab.app_settings_lookup_strategy = Mock(
+            settings=Mock(
+                return_value={"GLOBAL": {"spam": "eggs"}}
+            )
+        )
+        tab.workspace.app_settings_lookup_strategy =  Mock(
+            settings=Mock(
+                return_value={"GLOBAL": {"spam": "eggs"}}
+            )
+        )
+        with qtbot.wait_signal(tab.model().dataChanged):
+            tab.add_workflow(Spam)
+        with qtbot.wait_signal(tab.workflow_selected):
+            tab.set_current_workflow('spam')
+        assert tab.current_workflow() == "spam"
+    def test_workflows(self, qtbot):
+        class DummyWorkflow(speedwagon.Workflow):
+            name = "dummy 1"
+
+        base_model = models.TabsTreeModel()
+        base_model.append_workflow_tab(
+            "Dummy tab",
+            [
+                DummyWorkflow,
+            ]
+        )
+
+        model = models.TabProxyModel()
+        model.setSourceModel(base_model)
+        model.set_source_tab("Dummy tab")
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        tab.set_model(model)
+        assert "dummy 1" in tab.workflows
+
+    def test_workflow_selected(self, qtbot):
+        class DummyWorkflow(speedwagon.Workflow):
+            name = "dummy 1"
+            description = "Dummy Description"
+            def discover_task_metadata(self, *args, **kwargs):
+                return []
+
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        tab.add_workflow(DummyWorkflow)
+        tab.workspace.app_settings_lookup_strategy = \
+            Mock(
+                speedwagon.config.AbsConfigSettings,
+                settings=Mock(return_value={})
+        )
+        with qtbot.wait_signal(tab.workflow_selected):
+            qtbot.mouseClick(
+                tab.workflow_selector.workflowSelectionView.viewport(),
+                QtCore.Qt.LeftButton,
+                pos=tab.workflow_selector.workflowSelectionView.visualRect(
+                    tab.workflow_selector.model.index(0, 0)
+                ).center()
+            )
+
+    def test_workflow_selected_updates_workspace(self, qtbot):
+        class DummyWorkflow(speedwagon.Workflow):
+            name = "dummy 1"
+            description = "Dummy Description"
+
+            def discover_task_metadata(self, *args, **kwargs):
+                return []
+
+        tab = qtwidgets.tabs.WorkflowsTab3()
+        tab.workspace.app_settings_lookup_strategy = Mock(
+            speedwagon.config.AbsConfigSettings,
+            settings=Mock(return_value={})
+        )
+        tab.add_workflow(DummyWorkflow)
+
+        with qtbot.wait_signal(tab.workflow_selected):
+            qtbot.mouseClick(
+                tab.workflow_selector.workflowSelectionView.viewport(),
+                QtCore.Qt.LeftButton,
+                pos=tab.workflow_selector.workflowSelectionView.visualRect(
+                    tab.workflow_selector.model.index(0, 0)
+                ).center()
+            )
+        assert tab.workspace.name == "dummy 1"
\ No newline at end of file
diff --git a/tests/frontend/test_tabs.py b/tests/frontend/test_tabs.py
index 35bd7f1c5..e69de29bb 100644
--- a/tests/frontend/test_tabs.py
+++ b/tests/frontend/test_tabs.py
@@ -1,446 +0,0 @@
-from collections import OrderedDict
-from typing import List, Any, Dict
-from unittest import mock
-from unittest.mock import Mock, MagicMock, patch, call
-import yaml
-import pytest
-
-import speedwagon.exceptions
-import speedwagon.job
-
-QtCore = pytest.importorskip("PySide6.QtCore")
-QtWidgets = pytest.importorskip("PySide6.QtWidgets")
-
-from speedwagon.frontend.qtwidgets import tabs
-from speedwagon.frontend.qtwidgets.runners import QtRunner
-from speedwagon import worker
-from speedwagon.workflow import DirectorySelect
-
-
-class TestWorkflowsTab:
-    def test_exception_calls_message_box(self, qtbot, monkeypatch):
-        mock_log_manager = Mock()
-        workflows = MagicMock()
-
-        selection_tab = tabs.WorkflowsTab(
-            None,
-            workflows=workflows,
-            log_manager=mock_log_manager
-        )
-        selection_tab.get_item_options_model = \
-            Mock(
-                side_effect=speedwagon.exceptions.SpeedwagonException(
-                    "something wrong happened")
-            )
-        index = Mock()
-        selection_tab.item_selection_model = Mock()
-
-        mock_message_box_exec = MagicMock()
-
-        monkeypatch.setattr(
-            QtWidgets.QMessageBox,
-            "exec",
-            mock_message_box_exec
-        )
-
-        with pytest.raises(speedwagon.exceptions.SpeedwagonException) as e:
-            selection_tab.item_selected(index)
-        assert str(e.value) == "something wrong happened" and \
-               mock_message_box_exec.called is True
-
-    @pytest.mark.parametrize(
-        "number_of_indexes_selected, is_validate",
-        [
-            (0, False),
-            (1, True),
-            (2, False),
-        ]
-    )
-    def test_is_ready_to_start(
-            self,
-            qtbot,
-            number_of_indexes_selected,
-            is_validate
-    ):
-        log_manager = Mock()
-        work_manager = Mock()
-        selection_tab = tabs.WorkflowsTab(
-            parent=None,
-            workflows=MagicMock(),
-            log_manager=log_manager,
-            work_manager=work_manager
-        )
-        selection_tab.item_selector_view.selectedIndexes = \
-            Mock(
-                return_value=[
-                    Mock() for _ in range(number_of_indexes_selected)
-                ]
-            )
-        assert selection_tab.is_ready_to_start() is is_validate
-
-    def test_init_selects_first_workflow(self, qtbot):
-        log_manager = Mock()
-        work_manager = Mock(user_settings={})
-        workflows = OrderedDict()
-        workflows["Spam"] = MagicMock()
-        workflows["Bacon"] = MagicMock()
-
-        base_widget = QtWidgets.QWidget()
-        selection_tab = tabs.WorkflowsTab(
-            parent=base_widget,
-            workflows=workflows,
-            log_manager=log_manager,
-            work_manager=work_manager
-        )
-        selection_tab.init_selection()
-        assert selection_tab.item_selector_view.currentIndex().data() == \
-               workflows['Spam'].name
-
-    def test_start_calls_run_on_workflow(self, qtbot, monkeypatch):
-        log_manager = Mock()
-        work_manager = MagicMock(user_settings={})
-        workflows = OrderedDict()
-
-        class MockWorkflow(speedwagon.job.AbsWorkflow):
-            name = "Spam"
-
-            def discover_task_metadata(
-                    self, initial_results, additional_data, **user_args):
-                pass
-
-            def get_user_options(self):
-                return []
-
-            def user_options(self):
-                return []
-
-        workflows["Spam"] = MockWorkflow
-
-        selection_tab = tabs.WorkflowsTab(
-            parent=None,
-            workflows=workflows,
-            log_manager=log_manager,
-            work_manager=work_manager
-        )
-        from speedwagon.runner_strategies import RunRunner
-        mock_runner = Mock()
-        monkeypatch.setattr(RunRunner, "run", mock_runner)
-        selection_tab.start(workflows["Spam"])
-        assert isinstance(mock_runner.call_args_list[0][0][0], MockWorkflow)
-
-    @pytest.mark.parametrize(
-        "exception_type",
-        [
-            ValueError,
-            Exception
-        ]
-    )
-    def test_start_creates_a_messagebox_on_value_error(
-            self, qtbot, monkeypatch, exception_type):
-        work_manager = MagicMock(user_settings={})
-        workflows = OrderedDict()
-
-        class MockWorkflow(tabs.AbsWorkflow):
-            def discover_task_metadata(
-                    self,
-                    initial_results,
-                    additional_data,
-                    **user_args):
-                pass
-
-            def get_user_options(self):
-                return []
-
-            def user_options(self):
-                return []
-
-        workflows["Spam"] = MockWorkflow
-
-        selection_tab = tabs.WorkflowsTab(
-            parent=None,
-            workflows=workflows,
-            work_manager=work_manager
-        )
-
-        from speedwagon.runner_strategies import RunRunner
-
-        mock_runner = Mock(
-            side_effect=exception_type("something went wrong")
-        )
-
-        monkeypatch.setattr(RunRunner, "run", mock_runner)
-        mock_message_box_exec = Mock()
-
-        monkeypatch.setattr(
-            tabs.WorkflowsTab,
-            "_create_error_message_box_from_exception",
-            mock_message_box_exec
-        )
-
-        selection_tab.start(workflows["Spam"])
-
-        assert mock_message_box_exec.called is True
-
-    @pytest.mark.parametrize("exception_type", [
-        speedwagon.exceptions.JobCancelled,
-        ValueError,
-        TypeError
-    ])
-    def test_run_dialog_catch_exception(
-            self, qtbot, monkeypatch, exception_type):
-        workflows = {
-            "spam": MagicMock(name="spam")
-        }
-        workflows['spam'].__class__ = speedwagon.job.AbsWorkflow
-        workflows['spam'].name = "Spam"
-
-        log_manager = Mock()
-        work_manager = Mock()
-        work_manager.user_settings = {}
-
-        def cause_chaos(self, tools, options, logger, completion_callback):
-            raise exception_type
-
-        monkeypatch.setattr(QtRunner, "run", cause_chaos)
-
-        exec_ = Mock()
-
-        monkeypatch.setattr(tabs.QtWidgets.QMessageBox, "exec_", exec_)
-
-        selection_tab = tabs.WorkflowsTab(
-            parent=None,
-            workflows=workflows,
-            log_manager=log_manager,
-            work_manager=work_manager
-        )
-        selection_tab.run(workflows['spam'], {})
-        assert exec_.called is True
-
-    @pytest.fixture()
-    def dummy_workflow_cls_factory(self):
-        def _make_workflow(user_options):
-            class DummyWorkflow(tabs.Workflow):
-
-                def discover_task_metadata(self, initial_results: List[Any],
-                                           additional_data: Dict[str, Any],
-                                           **user_args) -> List[dict]:
-                    return []
-
-                def get_user_options(self):
-                    return user_options
-            return DummyWorkflow
-        return _make_workflow
-
-    @pytest.fixture()
-    def workflow_tab_factory(self, dummy_workflow_cls_factory):
-        def _make_workflow_tab(user_options):
-
-            base_widget = QtWidgets.QWidget()
-            workflows = {'dummy': dummy_workflow_cls_factory(user_options)}
-            log_manager = Mock()
-            work_manager = Mock(worker.ToolJobManager, user_settings={})
-            selection_tab = tabs.WorkflowsTab(
-                parent=base_widget,
-                workflows=workflows,
-                log_manager=log_manager,
-                work_manager=work_manager
-            )
-            return selection_tab
-        return _make_workflow_tab
-
-    @pytest.mark.parametrize(
-        "user_options,raises",
-        [
-            (
-                [
-                    DirectorySelect("Input", required=True),
-                ],
-                True
-            ),
-            (
-                [
-                    DirectorySelect("Input", required=False),
-                    DirectorySelect("Output", required=True),
-                ],
-                True
-            ),
-            (
-                [
-                    DirectorySelect("Input", required=True),
-                    DirectorySelect("Output", required=True),
-                ],
-                True
-            ),
-            (
-                [
-                    DirectorySelect("Output", required=False),
-                ],
-                False
-            ),
-            (
-                [
-                    DirectorySelect("Input", required=False),
-                    DirectorySelect("Output", required=False),
-                ],
-                False
-            )
-        ]
-    )
-    def test_warn_user_of_invalid_settings(
-            self,
-            qtbot,
-            monkeypatch,
-            workflow_tab_factory,
-            user_options,
-            raises
-    ):
-
-        error_message_box = Mock()
-        monkeypatch.setattr(
-            tabs.QtWidgets, "QMessageBox", Mock(return_value=error_message_box)
-        )
-        workflow_tab = workflow_tab_factory(user_options)
-
-        def checker(data):
-            return "I failed" if data.required else None
-
-        if raises:
-            with pytest.raises(speedwagon.exceptions.InvalidConfiguration):
-                workflow_tab.warn_user_of_invalid_settings([checker])
-            assert error_message_box.exec_.called is True
-        else:
-            assert workflow_tab.warn_user_of_invalid_settings([checker]) is None
-
-    def test_warn_user_of_invalid_settings_workflow(self, qtbot, workflow_tab_factory, monkeypatch):
-
-        value_widget = DirectorySelect("Input", required=True)
-        value_widget.value = "some value"
-
-        workflow_tab = workflow_tab_factory([value_widget])
-        workflow = Mock(speedwagon.job.Workflow)
-
-        monkeypatch.setattr(
-            tabs,
-            'get_workflow_errors',
-            Mock(return_value="got some errors")
-        )
-
-        monkeypatch.setattr(tabs.QtWidgets.QMessageBox, 'exec_', Mock())
-
-        with pytest.raises(speedwagon.exceptions.InvalidConfiguration):
-            workflow_tab.warn_user_of_invalid_settings(checks=[], workflow=workflow)
-
-    def test_start_with_errors_does_not_start(
-            self,
-            qtbot,
-            workflow_tab_factory
-    ):
-        no_value_widget = DirectorySelect("Input", required=True)
-        no_value_widget.value = None
-
-        workflow_tab = workflow_tab_factory([no_value_widget])
-        workflow_tab.start = Mock()
-        workflow_tab.warn_user_of_invalid_settings = \
-            Mock(side_effect=tabs.InvalidConfiguration)
-        qtbot.mouseClick(workflow_tab.actions_widgets['start_button'], QtCore.Qt.MouseButton.LeftButton)
-        assert workflow_tab.start.called is False
-
-    def test_valid_start_calls_start(
-            self,
-            qtbot,
-            workflow_tab_factory
-    ):
-        no_value_widget = DirectorySelect("Input", required=True)
-        no_value_widget.value = "foo"
-
-        workflow_tab = workflow_tab_factory([no_value_widget])
-        workflow_tab.start = Mock()
-        workflow_tab.warn_user_of_invalid_settings = Mock()
-        qtbot.mouseClick(
-            workflow_tab.actions_widgets['start_button'],
-            QtCore.Qt.MouseButton.LeftButton
-        )
-        assert workflow_tab.start.called is True
-    def test_invalid_config_starts_open_dialog_box(
-            self,
-            qtbot,
-            workflow_tab_factory,
-            monkeypatch
-    ):
-        no_value_widget = DirectorySelect("Input", required=True)
-
-        workflow_tab = workflow_tab_factory([no_value_widget])
-        workflow_tab.is_ready_to_start = Mock(return_value=True)
-        workflow_tab.start = Mock(
-            side_effect=speedwagon.exceptions.MissingConfiguration("oh no")
-        )
-        workflow_tab.warn_user_of_invalid_settings = Mock()
-        message_box = Mock()
-        monkeypatch.setattr(
-            tabs.QtWidgets, "QMessageBox", Mock(return_value=message_box)
-        )
-        qtbot.mouseClick(
-            workflow_tab.actions_widgets['start_button'],
-            QtCore.Qt.MouseButton.LeftButton
-        )
-        message_box.setWindowTitle.assert_called_with("Settings Error")
-
-class TestTabsYaml:
-    @pytest.mark.parametrize("exception_type", [
-        FileNotFoundError,
-        AttributeError,
-        yaml.YAMLError,
-    ])
-    def test_read_tabs_yaml_errors(self, monkeypatch, exception_type):
-        import os.path
-        with patch(
-                'speedwagon.frontend.qtwidgets.tabs.open',
-                mock.mock_open()
-        ):
-            monkeypatch.setattr(os.path, "getsize", lambda x: 1)
-            monkeypatch.setattr(yaml, "load", Mock(side_effect=exception_type))
-            with pytest.raises(exception_type) as e:
-                list(tabs.read_tabs_yaml('tabs.yml'))
-            assert e.type == exception_type
-
-    def test_read_tabs_yaml(self, monkeypatch):
-        sample_text = """my stuff:
-- Convert CaptureOne Preservation TIFF to Digital Library Access JP2
-- Convert CaptureOne TIFF to Digital Library Compound Object
-- Convert CaptureOne TIFF to Digital Library Compound Object and HathiTrust
-- Convert CaptureOne TIFF to Hathi TIFF package
-        """
-        import os
-        monkeypatch.setattr(os.path, 'getsize', lambda x: 1)
-        with patch('speedwagon.frontend.qtwidgets.tabs.open',
-                   mock.mock_open(read_data=sample_text)):
-            tab_data = list(tabs.read_tabs_yaml('tabs.yml'))
-            assert len(tab_data) == 1 and \
-                   len(tab_data[0][1].workflows) == 4
-
-    def test_write_tabs_yaml(self):
-        sample_tab_data = [tabs.TabData("dummy_tab", MagicMock())]
-        with patch(
-                'speedwagon.frontend.qtwidgets.tabs.open',
-                mock.mock_open()
-        ) as m:
-            tabs.write_tabs_yaml("tabs.yml", sample_tab_data)
-            assert m.called is True
-        handle = m()
-        handle.write.assert_has_calls([call('dummy_tab')])
-
-
-def test_get_workflow_errors_returns_none_if_found_nothing():
-    workflow = Mock()
-    options = {"someoption": "somevalue"}
-    assert tabs.get_workflow_errors(options, workflow) is None
-
-
-def test_get_workflow_errors_returns_string_if_found_something():
-    workflow = Mock(
-        validate_user_options=Mock(side_effect=ValueError('something wrong'))
-    )
-
-    options = {"someoption": "some invalid value"}
-
-    assert tabs.get_workflow_errors(options, workflow) == "something wrong"
diff --git a/tests/frontend/test_widgets.py b/tests/frontend/test_widgets.py
index 62e742974..36d52dfd8 100644
--- a/tests/frontend/test_widgets.py
+++ b/tests/frontend/test_widgets.py
@@ -5,11 +5,18 @@
 
 
 QtCore = pytest.importorskip("PySide6.QtCore")
-from PySide6 import QtWidgets, QtGui
+from PySide6 import QtWidgets, QtGui, QtCore
+import sys
+if sys.version_info < (3, 10):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
 
 import speedwagon.workflow
 import speedwagon.frontend.qtwidgets.widgets
-import speedwagon.frontend.qtwidgets.models
+from speedwagon.frontend.qtwidgets.models import options as option_models
+from speedwagon.frontend.qtwidgets import models
+from speedwagon import Workflow
 
 class TestDropDownWidget:
     def test_empty_widget_metadata(self, qtbot):
@@ -364,7 +371,7 @@ def test_update_model_boolean(self, qtbot):
             speedwagon.workflow.BooleanSelect("spam"),
             speedwagon.workflow.BooleanSelect("bacon")
         ]
-        model = speedwagon.frontend.qtwidgets.models.ToolOptionsModel4(data)
+        model = option_models.ToolOptionsModel4(data)
         form.set_model(model)
         checkbox: speedwagon.frontend.qtwidgets.widgets.CheckBoxWidget = \
             form._background.widgets['spam']
@@ -381,7 +388,7 @@ def test_update_model_file_select(self, qtbot):
             speedwagon.workflow.FileSelectData("input"),
             speedwagon.workflow.FileSelectData("output")
         ]
-        model = speedwagon.frontend.qtwidgets.models.ToolOptionsModel4(data)
+        model = option_models.ToolOptionsModel4(data)
 
         form.set_model(model)
         qtbot.keyClicks(form._background.widgets['input'].edit, "/someinput/file.txt")
@@ -399,7 +406,7 @@ def test_update_model_combobox(self, qtbot):
         data = [
             option
         ]
-        model = speedwagon.frontend.qtwidgets.models.ToolOptionsModel4(data)
+        model = option_models.ToolOptionsModel4(data)
 
         form.set_model(model)
         combobox: speedwagon.frontend.qtwidgets.widgets.ComboWidget = \
@@ -433,5 +440,140 @@ def test_paint_event_calls_draw_primitive(self, qtbot, monkeypatch):
             drawPrimitive
         )
 
+
         form.paintEvent(event)
         assert drawPrimitive.called is True
+
+    def test_is_valid(self, qtbot):
+        form = speedwagon.frontend.qtwidgets.widgets.DynamicForm()
+        form.is_valid()
+
+class TestPluginConfig:
+    def test_no_plugins_by_default(self, qtbot):
+        plugin_widget = speedwagon.frontend.qtwidgets.widgets.PluginConfig()
+        qtbot.addWidget(plugin_widget)
+        assert plugin_widget.enabled_plugins() == {}
+
+    def test_checkbox_selection(self, qtbot):
+        plugin_widget = speedwagon.frontend.qtwidgets.widgets.PluginConfig()
+        entry_point = Mock(metadata.EntryPoint)
+        entry_point.name = "Spam"
+        entry_point.module = "SpamPlugins"
+
+        plugin_widget.model.add_entry_point(entry_point)
+        qtbot.addWidget(plugin_widget)
+
+        plugin_widget.model.setData(
+            plugin_widget.model.index(0, 0),
+            QtCore.Qt.CheckState.Checked.value,
+            QtCore.Qt.ItemDataRole.CheckStateRole
+        )
+
+        assert plugin_widget.enabled_plugins() == {"SpamPlugins": ["Spam"]}
+
+    @pytest.fixture()
+    def plugin_with_spam(self, qtbot):
+        plugin_widget = speedwagon.frontend.qtwidgets.widgets.PluginConfig()
+        entry_point = Mock(metadata.EntryPoint)
+        entry_point.name = "Spam"
+
+        plugin_widget.model.add_entry_point(entry_point)
+        qtbot.addWidget(plugin_widget)
+        return plugin_widget
+
+    def test_changes_made_signal(self, qtbot, plugin_with_spam):
+
+        with qtbot.wait_signal(plugin_with_spam.changes_made):
+            plugin_with_spam.model.setData(
+                plugin_with_spam.model.index(0, 0),
+                QtCore.Qt.CheckState.Checked.value,
+                QtCore.Qt.ItemDataRole.CheckStateRole
+            )
+        assert plugin_with_spam.modified is True
+
+    def test_changes_made_signal_reverting_makes_arg_false(self, qtbot, plugin_with_spam):
+
+        with qtbot.wait_signal(plugin_with_spam.changes_made):
+            plugin_with_spam.model.setData(
+                plugin_with_spam.model.index(0, 0),
+                QtCore.Qt.CheckState.Checked.value,
+                QtCore.Qt.ItemDataRole.CheckStateRole
+            )
+        with qtbot.wait_signal(plugin_with_spam.changes_made) as signal:
+            plugin_with_spam.model.setData(
+                plugin_with_spam.model.index(0, 0),
+                QtCore.Qt.CheckState.Unchecked.value,
+                QtCore.Qt.ItemDataRole.CheckStateRole
+            )
+        assert plugin_with_spam.modified is False
+
+class TestWorkspace:
+    @pytest.fixture()
+    def sample_workflow_klass(self, qtbot):
+        class Spam(Workflow):
+            name = "Spam bacon eggs"
+            description = "some description"
+            def discover_task_metadata(
+                    self,
+                    initial_results,
+                    additional_data, **user_args):
+                return []
+        return Spam
+
+    def test_show_workflow_name(self, qtbot, sample_workflow_klass):
+        workspace = speedwagon.frontend.qtwidgets.widgets.Workspace()
+        workspace.app_settings_lookup_strategy = Mock()
+        workspace.set_workflow(sample_workflow_klass)
+        assert workspace.workflow_name == \
+               sample_workflow_klass.name
+
+    def test_show_workflow_description(self, qtbot, sample_workflow_klass):
+        workspace = speedwagon.frontend.qtwidgets.widgets.Workspace()
+        workspace.app_settings_lookup_strategy = Mock()
+        workspace.set_workflow(sample_workflow_klass)
+        assert workspace.workflow_description == \
+               sample_workflow_klass.description
+#
+class TestWorkflowSettingsEditor:
+    class GenWorkflow(speedwagon.Workflow):
+        name = "Generate OCR Files!"
+        def discover_task_metadata(
+                self,
+                initial_results,
+                additional_data,
+                **user_args):
+            return []
+        def workflow_options(self):
+            return [
+                speedwagon.workflow.TextLineEditData("Dummy config 1", required=True),
+                speedwagon.workflow.TextLineEditData("Other config 2", required=True)
+            ]
+
+#     # def test_s(self, qtbot):
+    def test_editor_data_changed(self, qtbot):
+        editor = speedwagon.frontend.qtwidgets.widgets.WorkflowSettingsEditor()
+        model = models.WorkflowSettingsModel()
+        model.add_workflow(self.GenWorkflow())
+        model.reset_modified()
+
+        editor.model = model
+
+        qtbot.addWidget(editor)
+
+        with qtbot.wait_signal(editor.workflow_settings_view.expanded):
+            editor.workflow_settings_view.expandAll()
+
+        index = model.index(0, 1, parent=model.index(0, 0))
+        with qtbot.waitSignal(editor.data_changed):
+            # without editor.show() focusWidget doesn't return the delegate
+            editor.show()
+
+            qtbot.mouseClick(
+                editor.workflow_settings_view.viewport(),
+                QtCore.Qt.LeftButton,
+                pos=editor.workflow_settings_view.visualRect(index).center()
+            )
+            delegate_widget = editor.workflow_settings_view.focusWidget()
+            qtbot.keyClicks(delegate_widget, "some data")
+            qtbot.keyClick(delegate_widget, QtCore.Qt.Key.Key_Enter)
+        assert model.modified() is True
diff --git a/tests/test_application_launcher.py b/tests/test_application_launcher.py
index 6f365fa13..fe89f1cca 100644
--- a/tests/test_application_launcher.py
+++ b/tests/test_application_launcher.py
@@ -5,56 +5,56 @@
 QtCore = pytest.importorskip("PySide6.QtCore")
 
 
-class TestSingleWorkflowLauncher:
-    @pytest.mark.parametrize("times_run_in_a_row", [1, 2, 5])
-    def test_commands_called(self, qtbot, monkeypatch, times_run_in_a_row):
-        from speedwagon.frontend.qtwidgets import dialog
-        import speedwagon.frontend.qtwidgets.gui_startup
-        monkeypatch.setattr(
-            speedwagon.frontend.qtwidgets.gui,
-            "MainWindow1",
-            Mock(
-                spec=speedwagon.frontend.qtwidgets.gui.MainWindow1
-            )
-        )
-        monkeypatch.setattr(
-            dialog,
-            "WorkProgressBar",
-            Mock(spec=dialog.dialogs.WorkProgressBar)
-        )
-        for _ in range(times_run_in_a_row):
-            single_item_launcher = \
-                frontend.qtwidgets.gui_startup.SingleWorkflowLauncher(app=None)
-
-
-
-
-            workflow = Mock(spec=Workflow)
-            workflow.discover_task_metadata = Mock(return_value=[])
-            workflow.name = "job"
-            workflow.__class__ = job.Workflow
-            workflow.validate_user_options = Mock(return_value=True)
-
-            monkeypatch.setattr(workflow, "create_new_task", Mock())
-            single_item_launcher.set_workflow(workflow)
-            single_item_launcher.options = {
-                "Input": "somefakeinput",
-                "Profile": "HathiTrust Tiff"
-            }
-            app_ = startup.ApplicationLauncher(strategy=single_item_launcher)
-            app_.initialize()
-            app_.run()
-            assert workflow.initial_task.called is True and \
-                   workflow.discover_task_metadata.called is True and \
-                   workflow.generate_report.called is True
-
-    def test_workflow_not_set_throw_exception_when_run(self):
-        pytest.importorskip("speedwagon.frontend.qtwidgets")
-        single_item_launcher = \
-            frontend.qtwidgets.gui_startup.SingleWorkflowLauncher(app=None)
-
-        with pytest.raises(AttributeError):
-            single_item_launcher.run()
+# class TestSingleWorkflowLauncher:
+#     @pytest.mark.parametrize("times_run_in_a_row", [1, 2, 5])
+#     def test_commands_called(self, qtbot, monkeypatch, times_run_in_a_row):
+#         from speedwagon.frontend.qtwidgets import dialog
+#         import speedwagon.frontend.qtwidgets.gui_startup
+#         monkeypatch.setattr(
+#             speedwagon.frontend.qtwidgets.gui,
+#             "MainWindow1",
+#             Mock(
+#                 spec=speedwagon.frontend.qtwidgets.gui.MainWindow1
+#             )
+#         )
+#         monkeypatch.setattr(
+#             dialog,
+#             "WorkProgressBar",
+#             Mock(spec=dialog.dialogs.WorkProgressBar)
+#         )
+#         for _ in range(times_run_in_a_row):
+#             single_item_launcher = \
+#                 frontend.qtwidgets.gui_startup.SingleWorkflowLauncher(app=None)
+#
+#
+#
+#
+#             workflow = Mock(spec=Workflow)
+#             workflow.discover_task_metadata = Mock(return_value=[])
+#             workflow.name = "job"
+#             workflow.__class__ = job.Workflow
+#             workflow.validate_user_options = Mock(return_value=True)
+#
+#             monkeypatch.setattr(workflow, "create_new_task", Mock())
+#             single_item_launcher.set_workflow(workflow)
+#             single_item_launcher.options = {
+#                 "Input": "somefakeinput",
+#                 "Profile": "HathiTrust Tiff"
+#             }
+#             app_ = startup.ApplicationLauncher(strategy=single_item_launcher)
+#             app_.initialize()
+#             app_.run()
+#             assert workflow.initial_task.called is True and \
+#                    workflow.discover_task_metadata.called is True and \
+#                    workflow.generate_report.called is True
+#
+#     def test_workflow_not_set_throw_exception_when_run(self):
+#         pytest.importorskip("speedwagon.frontend.qtwidgets")
+#         single_item_launcher = \
+#             frontend.qtwidgets.gui_startup.SingleWorkflowLauncher(app=None)
+#
+#         with pytest.raises(AttributeError):
+#             single_item_launcher.run()
 
 
 class TestApplicationLauncher:
diff --git a/tests/test_config.py b/tests/test_config.py
index 7465abfb2..cacbe1965 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,40 +1,612 @@
-from unittest.mock import mock_open, patch
+from __future__ import annotations
+from typing import Optional, List, TYPE_CHECKING, Any, Dict
+from unittest.mock import Mock, patch, mock_open, ANY, call
+
+import pytest
+
 import speedwagon.config
+from yaml import YAMLError
+
+
+if TYPE_CHECKING:
+    from speedwagon.workflow import AbsOutputOptionDataType
+    from speedwagon.config import FullSettingsData, SettingsData
+
+
+class TestCustomTabsYamlConfig:
+    def test_load_single_tab(self):
+        def data_reader():
+            return """Dummy:
+- Convert HathiTrust limited view to Digital library
+- Generate MARC.XML Files
+- Generate OCR Files
+- Make JP2
+- Medusa Preingest Curation"""
+        config_loader = speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.data_reader = data_reader
+        assert len(config_loader.data()) == 1
+
+    def test_yaml_errors_throws_tab_load_failure(self, monkeypatch):
+        def data_reader():
+            return ""
+
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.data_reader = data_reader
+        monkeypatch.setattr(speedwagon.config.yaml, "load", Mock(side_effect=YAMLError))
+        with pytest.raises(speedwagon.exceptions.TabLoadFailure):
+            config_loader.data()
+
+    def test_file_format_error_throws_tab_load_failure(self, monkeypatch):
+        def data_reader():
+            return ""
+
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.data_reader = data_reader
+        monkeypatch.setattr(config_loader, "decode_data", Mock(side_effect=speedwagon.exceptions.FileFormatError("Failed to parse file")))
+        with pytest.raises(speedwagon.exceptions.TabLoadFailure):
+            config_loader.data()
+
+    def test_file_not_found_raises_tab_load_failure(self):
+        def data_reader():
+            raise FileNotFoundError
+
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.data_reader = data_reader
+        with pytest.raises(speedwagon.exceptions.TabLoadFailure):
+            config_loader.data()
+
+    def test_invalid_yml_data(self, monkeypatch):
+        def data_reader():
+            return ""
+
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.data_reader = data_reader
+        monkeypatch.setattr(
+            speedwagon.config.yaml,
+            "load",
+            Mock(return_value="dummy")
+        )
+
+        with pytest.raises(speedwagon.exceptions.FileFormatError):
+            config_loader.file_reader_strategy.decode_tab_settings_yml_data(
+                "stuff"
+            )
+
+    def test_decode_data(self):
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.file_reader_strategy = \
+            Mock(
+                speedwagon.config.AbsTabsYamlFileReader,
+                name="file_reader_strategy"
+            )
+        config_loader.decode_data("data")
+        assert config_loader.file_reader_strategy.decode_tab_settings_yml_data.called is True
+
+    def test_save(self):
+        config_loader = \
+            speedwagon.config.CustomTabsYamlConfig(yaml_file="myfaketabs.yml")
+        config_loader.file_writer_strategy = \
+            Mock(speedwagon.config.AbsTabWriter, name="file_writer_strategy")
+        config_loader.save([])
+        assert config_loader.file_writer_strategy.save.called is True
+
+
+class TestTabsWriteStrategy:
+    def test_write_data_is_called(self, monkeypatch):
+        strategy = speedwagon.config.TabsYamlWriter()
+        write_data = Mock()
+        monkeypatch.setattr(strategy, "write_data", write_data)
+        strategy.save("fake_file.yml", [])
+        assert write_data.called is True
+
+    def test_write_data(self, monkeypatch):
+        strategy = speedwagon.config.TabsYamlWriter()
+        write_data = Mock()
+        serialize_tabs_yaml = Mock()
+        monkeypatch.setattr(strategy, "write_data", write_data)
+        monkeypatch.setattr(strategy, "serialize", serialize_tabs_yaml)
+        strategy.save("fake_file.yml", [("dummy", [])])
+        assert write_data.called is True
+
+    def test_serialize_tabs_yaml(self):
+        strategy = speedwagon.config.TabsYamlWriter()
+
+        tabs = [
+            speedwagon.config.CustomTabData(
+                "Spam",
+                [
+                    "Convert HathiTrust limited view to Digital library",
+                    "Generate MARC.XML Files"
+                ]
+            )
+        ]
+        result = strategy.serialize(tabs)
+        assert result == """Spam:
+- Convert HathiTrust limited view to Digital library
+- Generate MARC.XML Files
+"""
+
+
+class TestIniFileGlobalConfigManager:
+    def test_save(self):
+        test_ini_file = "sample.ini"
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.config_file = test_ini_file
+        config_manager.saver = Mock()
+        data = {
+            "GLOBAL": {
+                "debug": False,
+                'starting-tab': 'Dummy'
+            }
+        }
+        config_manager.save(data)
+        assert config_manager.saver.save.called is True
+
+    def test_save_without_config_file_is_noop(self):
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.saver = Mock(speedwagon.config.AbsConfigSaver)
+        config_manager.save(Mock())
+        config_manager.saver.assert_not_called()
+
+    def test_load(self):
+        class FakeLoader(speedwagon.config.AbsConfigLoader):
+            def get_settings(self):
+                return {
+                    "GLOBAL": {
+                        "debug": False,
+                        'starting-tab': 'Dummy'
+                    }
+                }
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.loader = FakeLoader()
+        expected = {
+            "GLOBAL": {'debug': False, 'starting-tab': 'Dummy'}
+        }
+        assert config_manager.data() == expected
+
+    @pytest.mark.parametrize(
+        "index, setter_type",
+        [
+            (0, speedwagon.config.DefaultsSetter),
+            (1, speedwagon.config.ConfigFileSetter),
+            (2, speedwagon.config.CliArgsSetter),
+        ]
+    )
+    def test_default_get_resolution_order(self, monkeypatch, index, setter_type):
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.config_file = "dummy.ini"
+        assert isinstance(
+            config_manager.get_resolution_order()[index],
+            setter_type
+        )
+
+    def test_get_resolution_order_with_setting_value(self):
+        config_manager = speedwagon.config.IniConfigManager()
+        custom_config_order = [speedwagon.config.DefaultsSetter()]
+        config_manager.config_resolution_order = custom_config_order
+        assert config_manager.get_resolution_order() == custom_config_order
+
+    def test_default_loader_strategy(self, monkeypatch):
+        config_manager = speedwagon.config.IniConfigManager()
+        monkeypatch.setattr(
+            speedwagon.config.StandardConfigFileLocator,
+            "get_config_file",
+            Mock(return_value="sample.ini"))
+        assert isinstance(
+            config_manager.loader_strategy(),
+            speedwagon.config.MixedConfigLoader
+        )
+
+    def test_loader_strategy_set(self):
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.loader = Mock(name="loader")
+        assert config_manager.loader_strategy() == config_manager.loader
+
+    def test_default_saver_strategy(self):
+        config_manager = speedwagon.config.IniConfigManager()
+        assert isinstance(
+            config_manager.save_strategy(),
+            speedwagon.config.IniConfigSaver
+        )
+
+    def test_save_strategy_set(self):
+        config_manager = speedwagon.config.IniConfigManager()
+        config_manager.saver = Mock(name="saver")
+        assert config_manager.save_strategy() == config_manager.saver
+
+
+class TestIniConfigSaver:
+    def test_save(self, monkeypatch):
+        saver_strategy = speedwagon.config.IniConfigSaver()
+        saver_strategy.write_data_to_file = Mock()
+        saver_strategy.save(
+            "dummy.ini",
+            {
+                "GLOBAL": {
+                    'debug': False,
+                    'starting-tab': 'Dummy'
+                }
+            }
+        )
+        expected = """[GLOBAL]
+debug = False
+starting-tab = Dummy
+
+"""
+        saver_strategy.write_data_to_file.assert_called_once_with(
+            "dummy.ini",
+            serialized_data=expected
+        )
 
 
 class TestConfigLoader:
-    def test_loads_from_setting_file(self):
-        data = """[GLOBAL]
-tessdata = /usr/home/data/tessdata
-starting-tab = Tools
-debug = True
-getmarc_server_url = http://127.0.0.1:5000/
-        """
-        with patch('configparser.open', mock_open(read_data=data)) as m:
-            config_file = "config.ini"
-            loader = speedwagon.config.ConfigLoader(config_file)
-            loader.resolution_strategy_order = [
-                speedwagon.config.DefaultsSetter(),
-                speedwagon.config.ConfigFileSetter(config_file),
-                speedwagon.config.CliArgsSetter(args=[]),
-            ]
-            result = loader.get_settings()
-            assert result['debug'] is True
+    def test_load(self):
+        class MockConfigSetter(speedwagon.config.AbsSetting):
+            def update(self, settings: Optional[FullSettingsData] = None):
+                return {
+                    "GLOBAL": {
+                        "starting-tab": "Dummy",
+                        "debug": False,
+                    }
+                }
+
+        saver_strategy = speedwagon.config.MixedConfigLoader()
+        saver_strategy.resolution_strategy_order = [
+            MockConfigSetter()
+        ]
+        assert saver_strategy.get_settings() == {
+            "GLOBAL": {
+                "starting-tab": "Dummy",
+                "debug": False,
+            }
+        }
 
-    def test_cli_overrides_setting_file(self):
-        data = """[GLOBAL]
-tessdata = /usr/home/data/tessdata
-starting-tab = Tools
+
+class TestConfigFileSetter:
+    def test_update(self):
+        saver_strategy = speedwagon.config.ConfigFileSetter("config.ini")
+        fake_data = """[GLOBAL]
+starting-tab = Dummy
 debug = False
-getmarc_server_url = http://127.0.0.1:5000/
-        """
-        with patch('configparser.open', mock_open(read_data=data)) as m:
-            config_file = "config.ini"
-            loader = speedwagon.config.ConfigLoader(config_file)
-            loader.resolution_strategy_order = [
-                speedwagon.config.DefaultsSetter(),
-                speedwagon.config.ConfigFileSetter(config_file),
-                speedwagon.config.CliArgsSetter(args=['--debug']),
+"""
+        saver_strategy.read_config_data = Mock(return_value=fake_data)
+        assert saver_strategy.update() == {
+            'GLOBAL': {
+                'debug': False,
+                'starting-tab': 'Dummy',
+            }
+        }
+    def test_read_config_data(self):
+        with patch('speedwagon.config.open', mock_open()) as mocked_file:
+            speedwagon.config.ConfigFileSetter.read_config_data("config.ini")
+            mocked_file.assert_called_once()
+
+class TestCliArgsSetter:
+    def test_update(self):
+        saver_strategy = speedwagon.config.CliArgsSetter()
+        saver_strategy.args = ['--debug']
+        result = saver_strategy.update()
+        assert result == {
+            'GLOBAL': {
+                'debug': True,
+            }
+        }
+
+
+class TestCreateBasicMissingConfigFile:
+    def test_ensure_config_file_calls_generate_default(self, monkeypatch):
+        generate_default = Mock()
+        monkeypatch.setattr(speedwagon.config.os.path, "exists", lambda path: False)
+        monkeypatch.setattr(speedwagon.config,
+            "generate_default",
+            generate_default
+        )
+
+        ensure_file = speedwagon.config.CreateBasicMissingConfigFile()
+        ensure_file.ensure_config_file("dummy.ini")
+        assert generate_default.called is True
+
+    def test_ensure_tabs_file(self, monkeypatch):
+        touch = Mock()
+        monkeypatch.setattr(
+            speedwagon.config.pathlib.Path,
+            "touch",
+            touch
+        )
+        ensure_file = speedwagon.config.CreateBasicMissingConfigFile()
+        ensure_file.ensure_tabs_file("dummy.yml")
+        assert touch.called is True
+
+
+class TestDefaultsSetter:
+    def test_update_has_globals(self):
+        setter = speedwagon.config.DefaultsSetter()
+        results = setter.update()
+        assert "GLOBAL" in results
+
+
+class TestStandardConfig:
+    def test_resolve_settings(self, monkeypatch):
+        config_settings = speedwagon.config.StandardConfig()
+        monkeypatch.setattr(
+            speedwagon.config.CliArgsSetter,
+            "update",
+            Mock(name='CliArgsSetter.update', return_value={})
+        )
+        monkeypatch.setattr(
+            speedwagon.config.ConfigFileSetter,
+            "update",
+            Mock(name='ConfigFileSetter.update', return_value={})
+        )
+        monkeypatch.setattr(
+            speedwagon.config.StandardConfigFileLocator,
+            "get_config_file",
+            Mock(name='StandardConfigFileLocator.get_config_file', return_value="foo.ini")
+        )
+
+        assert isinstance(config_settings.resolve_settings(), dict)
+
+    def test_resolve_settings_with_config_loader_strategy_set(self):
+        config_settings = speedwagon.config.StandardConfig()
+        config_settings.config_loader_strategy = \
+            Mock(speedwagon.config.AbsConfigLoader)
+
+        config_settings.settings()
+        assert \
+            config_settings.config_loader_strategy.get_settings.called is True
+
+
+class TestTabsYamlFileReader:
+    def test_read_file(self):
+        with patch('speedwagon.config.open', mock_open()) as mocked_file:
+            speedwagon.config.TabsYamlFileReader.read_file("dummy.yml")
+            mocked_file.assert_called_once()
+
+    def test_decode_tab_settings_yml_data(self):
+        reader = speedwagon.config.TabsYamlFileReader()
+        sample_data = """Spam:
+- Convert HathiTrust limited view to Digital library
+- Generate MARC.XML Files
+"""
+        assert reader.decode_tab_settings_yml_data(sample_data) == {
+            "Spam": [
+                "Convert HathiTrust limited view to Digital library",
+                "Generate MARC.XML Files"
             ]
-            result = loader.get_settings()
-            assert result['debug'] is True
+        }
+
+
+class TestAbsWorkflowSettingsManager:
+    class DummyWorkflow(speedwagon.Workflow):
+        name = "Dummy"
+
+        def discover_task_metadata(self, initial_results: List[Any],
+                                   additional_data: Dict[str, Any],
+                                   **user_args) -> List[dict]:
+            return []
+
+        def workflow_options(self) -> List[AbsOutputOptionDataType]:
+            input_path = speedwagon.workflow.DirectorySelect(
+                "Some input path"
+            )
+            return [input_path]
+
+    def test_get_workflow_settings(self, monkeypatch):
+        class DummyResolver(speedwagon.config.AbsWorkflowSettingsResolver):
+            def get_response(
+                    self,
+                    options: List[AbsOutputOptionDataType]
+            ) -> SettingsData:
+                return {
+                    "Some input path": "some path"
+                }
+
+        workflow = self.DummyWorkflow()
+
+        monkeypatch.setattr(
+            speedwagon.config.StandardConfigFileLocator,
+            "get_app_data_dir",
+            lambda *_: "."
+        )
+        settings_manager = speedwagon.config.WorkflowSettingsManager()
+        settings_manager.settings_getter_strategy = DummyResolver()
+        results = settings_manager.get_workflow_settings(workflow)
+        assert "Some input path" in results
+
+
+class TestWorkflowSettingsYAMLResolver:
+    class BaconWorkflow(speedwagon.Workflow):
+        name = "Bacon"
+
+        def discover_task_metadata(self, initial_results: List[Any],
+                                   additional_data: Dict[str, Any],
+                                   **user_args) -> List[dict]:
+            return []
+
+        def workflow_options(self) -> List[AbsOutputOptionDataType]:
+            input_path = speedwagon.workflow.DirectorySelect(
+                "Some input path"
+            )
+            return [input_path]
+
+    def test_get_config_data(self, monkeypatch):
+        resolver = speedwagon.config.WorkflowSettingsYAMLResolver("workflows.yml")
+        text = """
+Bacon:
+  - name: Some input path
+    value: /home/dummy/spam
+    
+"""
+        monkeypatch.setattr(resolver, 'read_file', lambda _: text)
+        monkeypatch.setattr(speedwagon.config.os.path, "exists", lambda _: True)
+        assert "Bacon" in resolver.get_config_data()
+
+    def test_get_response(self, monkeypatch):
+
+        workflow = TestWorkflowSettingsYAMLResolver.BaconWorkflow()
+        resolver = \
+            speedwagon.config.WorkflowSettingsYAMLResolver("workflows.yml")
+
+        config_data = {
+            "Bacon": [
+                {
+                    "name": "Some input path",
+                    "value": "/home/dummy/spam"
+                }
+            ]
+        }
+        monkeypatch.setattr(resolver, "get_config_data", lambda: config_data)
+        response = resolver.get_response(workflow)
+        assert "Some input path" in response
+
+
+class TestSettingsYamlSerializer:
+    @pytest.fixture()
+    def serializer(self):
+        return speedwagon.config.SettingsYamlSerializer()
+
+    def test_structure_workflow_data(self, serializer):
+        assert serializer.structure_workflow_data(
+            {'Some input path': '/home/dummy/spam'}
+        ) == [
+            {'name': 'Some input path', 'value': '/home/dummy/spam'}
+        ]
+
+    def test_serialize_structure_to_yaml(self, serializer):
+        data = {
+            'Dummy': [{'name': 'Some input path', 'value': '/home/dummy/spam'}]
+        }
+        assert serializer.serialize_structure_to_yaml(data) == """Dummy:
+  - name: Some input path
+    value: /home/dummy/spam
+"""
+class TestWorkflowSettingsYamlExporter:
+    class DummyWorkflow(speedwagon.Workflow):
+        name = "Dummy"
+
+        def discover_task_metadata(self, initial_results: List[Any],
+                                   additional_data: Dict[str, Any],
+                                   **user_args) -> List[dict]:
+            return []
+
+        def workflow_options(self) -> List[AbsOutputOptionDataType]:
+            input_path = speedwagon.workflow.DirectorySelect(
+                "Some input path"
+            )
+            return [input_path]
+
+    # class SpamWorkflow(speedwagon.Workflow):
+    #     name = "Spam"
+    #
+    #     def discover_task_metadata(self, initial_results: List[Any],
+    #                                additional_data: Dict[str, Any],
+    #                                **user_args) -> List[dict]:
+    #         return []
+    #
+    #     def configuration_options(self) -> List[AbsOutputOptionDataType]:
+    #         input_path = speedwagon.workflow.DirectorySelect(
+    #             "Some other path"
+    #         )
+    #         return [input_path]
+
+    def test_save_calls_write_data_to_file(self):
+        exporter = speedwagon.config.WorkflowSettingsYamlExporter("dummy.yml")
+        def write_data_to_file(data: str, file_name: str): pass
+
+        exporter.write_data_to_file = Mock(spec=write_data_to_file)
+        workflow = TestWorkflowSettingsYamlExporter.DummyWorkflow()
+        args: SettingsData = {
+            "Some input path": "/home/dummy/spam"
+        }
+        exporter.save(workflow, args)
+        assert exporter.write_data_to_file.called is True
+        exporter.write_data_to_file.assert_called_once_with(ANY, "dummy.yml")
+
+    def test_save_appending(self, monkeypatch):
+        def load(file_name, Loader):
+            return {
+                "Spam": [
+                    {"name": "Some other path", "value": "/home/dummy/spam"}
+                ]
+            }
+        # monkeypatch.setattr(speedwagon.config.yaml, "load", load)
+        exporter = speedwagon.config.WorkflowSettingsYamlExporter("dummy.yml")
+        def write_data_to_file(data: str, file_name: str): pass
+
+        exporter.write_data_to_file = Mock(spec=write_data_to_file)
+
+        workflow = TestWorkflowSettingsYamlExporter.DummyWorkflow()
+        args: SettingsData = {
+            "Some input path": "/home/dummy/Bacon"
+        }
+        exporter.get_existing_data = Mock(return_value={
+            "Spam": [
+                {"name": "Some other path", "value": "/home/dummy/spam"}
+            ]
+        })
+        exporter.save(workflow, args)
+        expected_text = """Dummy:
+  - name: Some input path
+    value: /home/dummy/Bacon
+Spam:
+  - name: Some other path
+    value: /home/dummy/spam
+"""
+        exporter.write_data_to_file.assert_any_call(expected_text, ANY)
+
+    def test_serialize_settings_data(self):
+        exporter = speedwagon.config.WorkflowSettingsYamlExporter("dummy.yml")
+        expected_text = """Dummy:
+  - name: Some input path
+    value: /home/dummy/spam
+"""
+        result = exporter.serialize_settings_data(
+            workflow=TestWorkflowSettingsYamlExporter.DummyWorkflow(),
+            settings={
+                "Some input path": "/home/dummy/spam"
+            }
+        )
+        assert result == expected_text
+
+    def test_write_data_to_file(self):
+        mocked_open = mock_open()
+        with patch('speedwagon.config.open', mocked_open) as mocked_file:
+            speedwagon.config.WorkflowSettingsYamlExporter.write_data_to_file("dummy data", "workflows.yml")
+            handle = mocked_file()
+        handle.write.assert_called_once_with("dummy data")
+
+class TestYAMLWorkflowConfigBackend:
+    class SpamWorkflow(speedwagon.Workflow):
+        name = "Spam"
+
+        def workflow_options(self) -> List[AbsOutputOptionDataType]:
+            tesseract_path = speedwagon.workflow.DirectorySelect(label="Tesseract data file location")
+            tesseract_path.required = True
+            return [tesseract_path]
+
+
+        def discover_task_metadata(self, initial_results, additional_data, **user_args):
+            return []
+
+    def test_get(self):
+        config = speedwagon.config.YAMLWorkflowConfigBackend()
+        config.workflow = TestYAMLWorkflowConfigBackend.SpamWorkflow()
+        config.yaml_file = "dummy.yml"
+        config.settings_resolver = \
+            Mock(
+                name='settings_resolver',
+                get_response=Mock(
+                    name='get_response',
+                    return_value=Mock(
+                        name="get",
+                        get=lambda key: {"Tesseract data file location": "/some/path"}.get(key)
+                    )
+                )
+            )
+
+        assert config.get("Tesseract data file location") == "/some/path"
\ No newline at end of file
diff --git a/tests/test_get_config.py b/tests/test_get_config.py
index 0a737a0e7..3c263d7b6 100644
--- a/tests/test_get_config.py
+++ b/tests/test_get_config.py
@@ -87,7 +87,7 @@ def test_nix_get_app_data_directory(monkeypatch, tmpdir):
     speedwagon_config = speedwagon.config.NixConfig()
     user_path = os.path.join(os.sep, "Users", "someuser")
     monkeypatch.setattr(
-        speedwagon.config.Path,
+        speedwagon.config.pathlib.Path,
         "home",
         lambda *args, **kwargs: pathlib.Path(user_path)
     )
@@ -101,7 +101,7 @@ def test_nix_get_user_data_directory(monkeypatch):
     speedwagon_config = speedwagon.config.NixConfig()
     user_path = os.path.join(os.sep, "Users", "someuser")
     monkeypatch.setattr(
-        speedwagon.config.Path,
+        speedwagon.config.pathlib.Path,
         "home",
         lambda *args, **kwargs: pathlib.Path(user_path)
     )
@@ -252,7 +252,13 @@ def test_add_empty_keys_if_missing(tmpdir, monkeypatch):
     assert missing_keys is None
 
 
-def test_config_manager_empty_settings():
-    config_manager = speedwagon.config.ConfigManager("config.ini")
-    config_manager.cfg_parser = None
-    assert config_manager.global_settings == {}
+class TestConfigManager:
+    def test_config_manager_empty_settings(self):
+        config_manager = speedwagon.config.ConfigManager("config.ini")
+        config_manager.cfg_parser = None
+        assert config_manager.global_settings == {}
+
+    def test_config_manager_empty_plugins(self):
+        config_manager = speedwagon.config.ConfigManager("config.ini")
+        config_manager.cfg_parser = None
+        assert config_manager.plugins == {}
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index fc5e33b75..6d13f2d8b 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -1,10 +1,19 @@
+import importlib
+import sys
+if sys.version_info < (3, 10):
+    import importlib_metadata as metadata
+else:
+    from importlib import metadata
+
 import os.path
+from typing import Dict, Type
 from unittest.mock import Mock, patch, mock_open, call
 import json
 
 import pytest
 
 import speedwagon.job
+from speedwagon import Workflow
 
 
 def test_all_required_workflow_keys(monkeypatch):
@@ -16,7 +25,7 @@ def mocked_workflows():
         }
 
     monkeypatch.setattr(
-        speedwagon.job, "available_workflows", mocked_workflows
+        speedwagon.job, "find_builtin_workflows", mocked_workflows
     )
 
     assert speedwagon.job.all_required_workflow_keys() == {
@@ -142,9 +151,141 @@ def test_load(self, mock_strategy):
         serializer.load()
         assert mock_strategy.load.called is True
 
-#
-# class TestUserOption:
-#     def test_s(self):
-#         factory = speedwagon.job.UserOptions()
-#         option = factory.create("file_selection")
-#         pass
\ No newline at end of file
+
+@pytest.fixture
+def sample_plugins(monkeypatch):
+    monkeypatch.syspath_prepend(os.path.join(os.path.dirname(__file__), "test_plugins"))
+    return {
+        'speedwagon-dummy': importlib.import_module('speedwagon_dummy').MyWorkflow
+    }
+
+
+def test_load_plugins(sample_plugins):
+
+    class LoadSamplePlugins(speedwagon.job.AbsPluginFinder):
+
+        def locate(self) -> Dict[str, Type[Workflow]]:
+            return {
+                sample_plugins['speedwagon-dummy'].name:
+                    sample_plugins['speedwagon-dummy']
+            }
+
+    workflows = speedwagon.job.find_plugin_workflows(LoadSamplePlugins())
+    assert workflows['My Workflow'].name == 'My Workflow'
+
+
+class TestLoadAllPluginSearch:
+    def test_finding_nothing_returns_empty_dict(self, monkeypatch):
+        finder = speedwagon.job.LoadAllPluginSearch()
+        def get_entry_points(*_):
+            return []
+        monkeypatch.setattr(finder, "get_entry_points", get_entry_points)
+        assert finder.locate() == {}
+
+    def test_finding_dict(self, monkeypatch):
+        class FakeWork:
+            pass
+        finder = speedwagon.job.LoadAllPluginSearch()
+        def get_entry_points(*_):
+            return [
+                Mock(metadata.EntryPoint)
+            ]
+        monkeypatch.setattr(finder, "get_entry_points", get_entry_points)
+
+        monkeypatch.setattr(
+            finder,
+            "load_workflow_from_entry_point",
+            lambda *_: ("dummy", FakeWork)
+        )
+
+        assert finder.locate() == {"dummy": FakeWork}
+
+    def test_failed_throws_exception(self, monkeypatch):
+        finder = speedwagon.job.LoadAllPluginSearch()
+        def get_entry_points(*_):
+            return [
+                Mock(metadata.EntryPoint)
+            ]
+        monkeypatch.setattr(finder, "get_entry_points", get_entry_points)
+
+        def load_workflow_from_entry_point(entry_point):
+            entry_point = Mock(metadata.EntryPoint)
+            entry_point.name = "foo"
+            raise speedwagon.exceptions.InvalidPlugin("nope!", entry_point=entry_point)
+            # finder.plugins_failed_to_import.append("spam")
+
+        monkeypatch.setattr(
+            finder, "load_workflow_from_entry_point",
+            load_workflow_from_entry_point
+        )
+
+        with pytest.raises(speedwagon.exceptions.PluginImportError):
+            finder.locate()
+
+    def test_load_workflow_from_entry_point(self, monkeypatch):
+        finder = speedwagon.job.LoadAllPluginSearch()
+        class FakeWorkflow(speedwagon.Workflow):
+            name = "dummy"
+
+        entry_point = Mock(metadata.EntryPoint, load=Mock(return_value=FakeWorkflow))
+        res = finder.load_workflow_from_entry_point(entry_point)
+        assert res[1] == FakeWorkflow
+
+    def test_load_workflow_from_entry_point_bad_data_raises_invalid_plugin_execption(self, monkeypatch):
+        finder = speedwagon.job.LoadAllPluginSearch()
+        class InvalidWorkflow:
+            name = "not subclassed from speedwagon.Workflow"
+
+        entry_point = Mock(
+            metadata.EntryPoint,
+            value="dummy:badplugin",
+            load=Mock(return_value=InvalidWorkflow)
+        )
+        with pytest.raises(speedwagon.exceptions.InvalidPlugin):
+            finder.load_workflow_from_entry_point(entry_point)
+
+
+class TestLoadActivePluginsOnly:
+    def test_only_load_whitelisted(self, monkeypatch):
+        class WhitelistedWorkflow(speedwagon.Workflow):
+            name = "whitelisted"
+
+        class BlacklistedWorkflow(speedwagon.Workflow):
+            name = "blacklisted"
+
+        def get_entry_points(*_):
+            white_listed_plugin = Mock(
+                metadata.EntryPoint,
+                module="myplugins",
+                load=Mock(return_value=WhitelistedWorkflow)
+            )
+            white_listed_plugin.name = "whitelisted_workflow"
+
+            black_listed_plugin = Mock(
+                metadata.EntryPoint,
+                module="myplugins",
+                load=Mock(return_value=BlacklistedWorkflow)
+            )
+            black_listed_plugin.name = "backlisted"
+
+            return [
+                white_listed_plugin,
+                black_listed_plugin
+            ]
+
+        plugin_loader = speedwagon.job.LoadWhiteListedPluginsOnly()
+        monkeypatch.setattr(plugin_loader, "get_entry_points", get_entry_points)
+
+        plugin_loader.whitelisted_entry_points = {
+            ('myplugins', 'whitelisted_workflow')
+        }
+
+        located = plugin_loader.locate()
+        assert all(['whitelisted' in located, 'blacklisted' not in located])
+
+
+class TestLoadWhiteListedPluginsOnly:
+    def test_setting_invalid_whitelist_format_raise_value_error(self):
+        plugin_loader = speedwagon.job.LoadWhiteListedPluginsOnly()
+        with pytest.raises(TypeError):
+            plugin_loader.whitelisted_entry_points = "badvalue"
\ No newline at end of file
diff --git a/tests/test_plugins/speedwagon_dummy.py b/tests/test_plugins/speedwagon_dummy.py
new file mode 100644
index 000000000..a9ef9db78
--- /dev/null
+++ b/tests/test_plugins/speedwagon_dummy.py
@@ -0,0 +1,10 @@
+from typing import List, Any, Dict
+import speedwagon
+class MyWorkflow(speedwagon.Workflow):
+    name = 'My Workflow'
+
+    def discover_task_metadata(self, initial_results: List[Any],
+                               additional_data: Dict[str, Any], **user_args) -> \
+    List[dict]:
+        return []
+
diff --git a/tests/test_reports.py b/tests/test_reports.py
new file mode 100644
index 000000000..15f84b762
--- /dev/null
+++ b/tests/test_reports.py
@@ -0,0 +1,47 @@
+from speedwagon import reports
+
+
+class TestExceptionReportExporter:
+    def test_title(self):
+        reporter = reports.ExceptionReport()
+        reporter.exception = ValueError("This is wrong")
+        assert reporter.title() == "ValueError"
+
+    def test_title_empty(self):
+        reporter = reports.ExceptionReport()
+        assert reporter.title() == ""
+
+    def test_report_empty(self):
+        reporter = reports.ExceptionReport()
+        assert reporter.report() == ""
+
+    def test_report(self, monkeypatch):
+
+        def format_tb(*_, **__):
+            return [
+                '  File "/opt/speedwagon/frontend/qtwidgets/tabs.py", '
+                'line 102, in _handle_workflow_changed',
+                '    self.workspace.set_workflow(workflow_klass)',
+                '',
+                '  File "/opt/speedwagon/frontend/qtwidgets/widgets.py", '
+                'line 632, in set_workflow',
+                '    models.ToolOptionsModel4(new_workflow.get_user_options())',
+                '                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^',
+                '',
+                'File "/opt/speedwagon/workflows/workflow_completeness.py", '
+                'line 45, in get_user_options'
+                '    raise TypeError("I failed")'
+            ]
+        monkeypatch.setattr(reports.traceback, "format_tb", format_tb)
+        reporter = reports.ExceptionReport()
+        reporter.exception = ValueError("This is wrong")
+        assert 'raise TypeError("I failed")' in reporter.report()
+
+    def test_summary(self):
+        reporter = reports.ExceptionReport()
+        reporter.exception = ValueError("This is wrong")
+        assert reporter.summary() == "This is wrong"
+
+    def test_summary_empty(self):
+        reporter = reports.ExceptionReport()
+        assert reporter.summary() == ""
diff --git a/tests/test_runner_strategies.py b/tests/test_runner_strategies.py
index 42292a0a1..226b8a223 100644
--- a/tests/test_runner_strategies.py
+++ b/tests/test_runner_strategies.py
@@ -1,13 +1,15 @@
+from __future__ import annotations
 import logging
 import os
 
 import pytest
 from unittest.mock import Mock, MagicMock
-from typing import List, Any, Dict
+from typing import List, Any, Dict, TYPE_CHECKING
 
 import speedwagon.exceptions
 from speedwagon import runner_strategies, tasks
 import speedwagon
+# from tasks import TaskBuilder
 
 
 # @pytest.mark.filterwarnings(
@@ -545,14 +547,18 @@ def test_manager_does_nothing(self):
         with runner_strategies.BackgroundJobManager() as manager:
             assert manager is not None
 
-    def test_job_finished_called(self):
+    def test_job_finished_called(self, monkeypatch):
         callbacks = Mock(name="callbacks")
 
         liaison = runner_strategies.JobManagerLiaison(
             callbacks=callbacks,
             events=Mock()
         )
-
+        monkeypatch.setattr(
+            speedwagon.config.StandardConfigFileLocator,
+            "get_app_data_dir",
+            lambda *_: "."
+        )
         with runner_strategies.BackgroundJobManager() as manager:
             manager.valid_workflows = {"spam": SpamWorkflow}
             manager.submit_job(
diff --git a/tests/test_startup.py b/tests/test_startup.py
index c0f7055ed..211b7d096 100644
--- a/tests/test_startup.py
+++ b/tests/test_startup.py
@@ -63,33 +63,17 @@ def test_get_custom_tabs_missing_file(capsys, monkeypatch):
     all_workflows = {
         "my workflow": Mock()
     }
-    monkeypatch.setattr(os.path, "exists", lambda x: False)
+    monkeypatch.setattr(speedwagon.config.CustomTabsYamlConfig, "data", Mock(side_effect=FileNotFoundError))
     list(speedwagon.startup.get_custom_tabs(all_workflows, "not_a_real_file"))
     captured = capsys.readouterr()
     assert "file not found" in captured.err
 
 
-def test_get_custom_tabs_bad_data_raises_exception(monkeypatch):
-    test_file = "test.yml"
-    monkeypatch.setattr(os.path, "exists", lambda x: x == test_file)
-    with pytest.raises(speedwagon.startup.FileFormatError):
-        with patch('speedwagon.startup.open', mock_open(
-                read_data='not valid yml data')):
-            list(
-                speedwagon.startup.get_custom_tabs(
-                    {
-                        "my workflow": Mock(spec=speedwagon.Workflow)
-                    },
-                    test_file
-                )
-            )
-
-
 def test_missing_workflow(monkeypatch, capsys):
     test_file = "test.yml"
     monkeypatch.setattr(os.path, "exists", lambda x: x == test_file)
 
-    # These workflow are not valid
+    # These workflows are not valid
     tabs_config_data = {
         "my workflow": [
             "spam",
@@ -100,17 +84,20 @@ def test_missing_workflow(monkeypatch, capsys):
     load = Mock(name="load", return_value=tabs_config_data)
     load.__class__ = dict
     monkeypatch.setattr(yaml, "load", load)
-    with patch('speedwagon.startup.open', mock_open()):
-        list(
-            speedwagon.startup.get_custom_tabs(
-                {
-                    "my workflow": Mock()
-                },
-                test_file
-            )
+    def read_file(*args, **kwargs):
+        return ""
+    monkeypatch.setattr(speedwagon.config.TabsYamlFileReader, "read_file", read_file)
+
+    list(
+        speedwagon.startup.get_custom_tabs(
+            {
+                "my workflow": Mock()
+            },
+            test_file
         )
+    )
     captured = capsys.readouterr()
-    assert "Unable to load" in captured.err
+    assert "Unable to load workflow" in captured.err
 
 
 def test_get_custom_tabs_loads_workflows_from_file(monkeypatch):
@@ -127,7 +114,10 @@ def test_get_custom_tabs_loads_workflows_from_file(monkeypatch):
     load = Mock(name="load", return_value=tabs_config_data)
     load.__class__ = dict
     monkeypatch.setattr(yaml, "load", load)
-    with patch('speedwagon.startup.open', mock_open()):
+    def read_file(*args, **kwargs):
+        return ""
+    monkeypatch.setattr(speedwagon.config.TabsYamlFileReader, "read_file", read_file)
+    with patch('speedwagon.config.open', mock_open()):
         tab_name, workflows = next(
             speedwagon.startup.get_custom_tabs(all_workflows, test_file)
         )
@@ -138,48 +128,58 @@ class TestCustomTabsFileReader:
     def test_load_custom_tabs_file_not_found(self, capsys):
         all_workflows = Mock()
         reader = speedwagon.startup.CustomTabsFileReader(all_workflows)
-        reader.read_yml_file = Mock()
-        reader.read_yml_file.side_effect = FileNotFoundError()
 
-        fake_file = Mock()
-        all(reader.load_custom_tabs(fake_file))
+        def read_file():
+            raise FileNotFoundError("File not found")
+
+        tabs_config_strategy = \
+            speedwagon.config.CustomTabsYamlConfig("fake_file.yml")
+
+        tabs_config_strategy.data_reader = read_file
+        all(reader.load_custom_tabs(strategy=tabs_config_strategy))
         captured = capsys.readouterr()
-        assert "Custom tabs file not found" in captured.err
+        assert "Custom tabs file fake_file.yml not found" in captured.err
 
     def test_load_custom_tabs_file_attribute_error(self, capsys):
         all_workflows = Mock()
         reader = speedwagon.startup.CustomTabsFileReader(all_workflows)
-        reader.read_yml_file = Mock()
-        reader.read_yml_file.side_effect = AttributeError()
 
-        fake_file = Mock()
-        all(reader.load_custom_tabs(fake_file))
+        tabs_config_strategy = \
+            speedwagon.config.CustomTabsYamlConfig("fake_file")
+        def read_file():
+            raise AttributeError()
+        tabs_config_strategy.data_reader = read_file
+
+        all(reader.load_custom_tabs(tabs_config_strategy))
         captured = capsys.readouterr()
-        assert "Custom tabs file failed to load" in captured.err
+        assert "Custom tabs failed to load" in captured.err
 
-    def test_load_custom_tabs_file_yaml_error(self, capsys):
+    def test_load_custom_tabs_file_yaml_error(self, capsys, monkeypatch):
 
         all_workflows = Mock()
         reader = speedwagon.startup.CustomTabsFileReader(all_workflows)
-        reader.read_yml_file = Mock()
-        reader.read_yml_file.side_effect = yaml.YAMLError()
-
-        fake_file = Mock()
-        all(reader.load_custom_tabs(fake_file))
+        class YamlReader(speedwagon.config.AbsTabsYamlFileReader):
+            def read_file(self, file_name) -> str:
+                return ""
+            def decode_tab_settings_yml_data(self, data):
+                raise yaml.YAMLError()
+        strategy = speedwagon.config.CustomTabsYamlConfig("fake_file")
+        strategy.file_reader_strategy = YamlReader()
+        all(reader.load_custom_tabs(strategy))
         captured = capsys.readouterr()
         assert "file failed to load" in captured.err
 
     def test_load_custom_tabs_file_error_loading_tab(self, capsys):
         all_workflows = Mock()
         reader = speedwagon.startup.CustomTabsFileReader(all_workflows)
-        reader.read_yml_file = Mock(return_value={"my tab": []})
-        reader._get_tab_items = Mock()
-        reader._get_tab_items.side_effect = TypeError()
+        strategy = speedwagon.config.CustomTabsYamlConfig("fake_file")
+        def read_file() -> str:
+            raise TypeError()
+        strategy.data_reader = read_file
+        all(reader.load_custom_tabs(strategy))
 
-        fake_file = Mock()
-        all(reader.load_custom_tabs(fake_file))
         captured = capsys.readouterr()
-        assert "Error loading tab" in captured.err
+        assert "Custom tabs failed to load" in captured.err
 
 
 def test_run_command_invalid():
@@ -193,7 +193,8 @@ def test_run_command_valid(monkeypatch):
     command = Mock(return_value=good)
     monkeypatch.setattr(speedwagon.config.sys, "argv", ["speedwagon", "run"])
     monkeypatch.setattr(speedwagon.config.sys, "argv", ["speedwagon", "run"])
-    monkeypatch.setattr(speedwagon.config.Path, "home", lambda: "/home/dummy")
+    monkeypatch.setattr(speedwagon.config.pathlib.Path, "home", lambda: "/home/dummy")
+    monkeypatch.setattr(speedwagon.startup, "get_global_options", lambda: {})
 
     monkeypatch.setattr(
         speedwagon.config.WindowsConfig,
diff --git a/tests/test_workflow.py b/tests/test_workflow.py
index 77852442c..cdf1bf05f 100644
--- a/tests/test_workflow.py
+++ b/tests/test_workflow.py
@@ -14,3 +14,4 @@ def test_AbsOutputOptionDataType_needs_widget_name():
         class BadClass(speedwagon.workflow.AbsOutputOptionDataType):
             pass
         BadClass(label="Dummy")
+
diff --git a/tests/workflows/test_batch_to_hathitrust.py b/tests/workflows/test_batch_to_hathitrust.py
index 7180f30d2..93d6c4bf4 100644
--- a/tests/workflows/test_batch_to_hathitrust.py
+++ b/tests/workflows/test_batch_to_hathitrust.py
@@ -23,7 +23,7 @@ def test_hathi_limited_to_dl_compound_has_options(index, label):
         workflow = \
             workflow_hathi_limited_to_dl_compound.HathiLimitedToDLWorkflow()
 
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
@@ -81,60 +81,6 @@ def mock_scandir(path):
            len(created_task.results[0].items) == number_of_fake_files
 
 
-
-# def test_get_additional_info(qtbot, monkeypatch):
-#     with warnings.catch_warnings():
-#         warnings.simplefilter("ignore")
-#         workflow = wf.CaptureOneBatchToHathiComplete()
-#     mock_package = MagicMock()
-#     mock_data = {
-#             "ID": "99423682912205899",
-#             "ITEM_NAME": "",
-#             "TITLE_PAGE": "99423682912205899_0001.tif",
-#             "PATH": "/some/random/path/"
-#             }
-#
-#     def mock_get_item(obj, key):
-#         return mock_data.get(key.name, str(key))
-#
-#     mock_package.metadata.__getitem__ = mock_get_item
-#     mock_package.__len__ = lambda x: 1
-#
-#     pretask_result = speedwagon.tasks.Result(
-#         source=wf.FindCaptureOnePackageTask,
-#         data=[mock_package]
-#     )
-#
-#     def patched_package_browser(packages, parent):
-#         patched_browser = \
-#             title_page_selection.PackageBrowser(packages, parent)
-#
-#         patched_browser.exec = Mock()
-#         patched_browser.result = Mock(return_value=patched_browser.Accepted)
-#         data = MagicMock()
-#         data.metadata = MagicMock()
-#
-#         data.metadata.__getitem__ = \
-#             lambda _, k: mock_data.get(k.name, str(k))
-#
-#         patched_browser.data = Mock(return_value=[data])
-#         return patched_browser
-#
-#     with monkeypatch.context() as mp:
-#         mp.setattr(wf, "PackageBrowser", patched_package_browser)
-#
-#         extra_data = workflow.get_additional_info(
-#             parent=None,
-#             options={},
-#             pretask_results=[pretask_result]
-#         )
-#
-#     assert extra_data['title_pages']['99423682912205899'] == \
-#            "99423682912205899_0001.tif"
-#
-#     assert isinstance(extra_data, dict)
-
-
 @pytest.fixture
 def unconfigured_workflow():
     with warnings.catch_warnings():
@@ -144,7 +90,7 @@ def unconfigured_workflow():
                 "getmarc_server_url": "http://fake.com"
             }
         )
-    user_options = {i.label: i.value for i in workflow.get_user_options()}
+    user_options = {i.label: i.value for i in workflow.job_options()}
     user_options['Identifier type'] = None
     return workflow, user_options
 
diff --git a/tests/workflows/test_capture_one_to_dl_compound_and_dl.py b/tests/workflows/test_capture_one_to_dl_compound_and_dl.py
index d7e91c7c0..af8e2ab50 100644
--- a/tests/workflows/test_capture_one_to_dl_compound_and_dl.py
+++ b/tests/workflows/test_capture_one_to_dl_compound_and_dl.py
@@ -201,7 +201,7 @@ def mock_scandir(path):
 def user_options():
     workflow = ht_wf.CaptureOneToDlCompoundAndDLWorkflow()
     return {
-        data.label: data.value for data in workflow.get_user_options()
+        data.label: data.value for data in workflow.job_options()
     }
 
 
diff --git a/tests/workflows/test_completeness.py b/tests/workflows/test_completeness.py
index 9e72793e9..01a3b411e 100644
--- a/tests/workflows/test_completeness.py
+++ b/tests/workflows/test_completeness.py
@@ -16,7 +16,7 @@
 ])
 def test_completeness_workflow_options(index, label):
     workflow = workflow_completeness.CompletenessWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
@@ -44,7 +44,7 @@ def test_initial_task_creates_task():
 @pytest.fixture
 def unconfigured_workflow():
     workflow = workflow = workflow_completeness.CompletenessWorkflow()
-    user_options = {i.label: i.value for i in workflow.get_user_options()}
+    user_options = {i.label: i.value for i in workflow.job_options()}
 
     return workflow, user_options
 
diff --git a/tests/workflows/test_convert_capture_one_preservation_to_digital_lib_jp2.py b/tests/workflows/test_convert_capture_one_preservation_to_digital_lib_jp2.py
index 7216e9e24..4067455b1 100644
--- a/tests/workflows/test_convert_capture_one_preservation_to_digital_lib_jp2.py
+++ b/tests/workflows/test_convert_capture_one_preservation_to_digital_lib_jp2.py
@@ -115,7 +115,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_validate_user_options_valid(
diff --git a/tests/workflows/test_create_checksums.py b/tests/workflows/test_create_checksums.py
index 401f0ce98..3932e58d3 100644
--- a/tests/workflows/test_create_checksums.py
+++ b/tests/workflows/test_create_checksums.py
@@ -4,11 +4,11 @@
 
 def test_singleChecksum_has_options():
     workflow = MakeChecksumBatchSingleWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
 
 
 def test_multipleChecksum_has_options():
     workflow = MakeChecksumBatchMultipleWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
diff --git a/tests/workflows/test_get_marc.py b/tests/workflows/test_get_marc.py
index 632c2e223..72e7d3fcf 100644
--- a/tests/workflows/test_get_marc.py
+++ b/tests/workflows/test_get_marc.py
@@ -13,12 +13,10 @@
 
 @pytest.fixture
 def unconfigured_workflow():
-    workflow = workflow_get_marc.GenerateMarcXMLFilesWorkflow(
-        global_settings={
-            "getmarc_server_url": "http://fake.com"
-        }
-    )
-    user_options = {i.label: i.value for i in workflow.get_user_options()}
+    workflow = workflow_get_marc.GenerateMarcXMLFilesWorkflow()
+    options_backend = Mock(get=lambda key: {"Getmarc server url": "http://fake.com"}.get(key))
+    workflow.set_options_backend(options_backend)
+    user_options = {i.label: i.value for i in workflow.job_options()}
     user_options['Identifier type'] = "Bibid"
     return workflow, user_options
 
@@ -166,16 +164,6 @@ def test_identifier_splits(identifier_type, subdirectory, expected_identifier,
     assert expected_volume == actual_volume
 
 
-def test_missing_server_url_fails(unconfigured_workflow):
-    workflow, user_options = unconfigured_workflow
-
-    if "getmarc_server_url" in workflow.global_settings:
-        del workflow.global_settings["getmarc_server_url"]
-
-    with pytest.raises(speedwagon.exceptions.MissingConfiguration):
-        workflow.discover_task_metadata([], None, **user_options)
-
-
 def test_generate_report_success(unconfigured_workflow):
     workflow, user_options = unconfigured_workflow
     report = workflow.generate_report(
@@ -273,13 +261,6 @@ def test_create_new_task(unconfigured_workflow, identifier_type,
            task_generated.identifier == identifier
 
 
-def test_missing_server_url_init_raises():
-    with pytest.raises(speedwagon.exceptions.MissingConfiguration):
-        workflow_get_marc.GenerateMarcXMLFilesWorkflow(
-            global_settings={}
-        )
-
-
 def test_955_field_defaults_to_true(unconfigured_workflow):
     workflow, user_options = unconfigured_workflow
     assert user_options['Add 955 field'] is True
diff --git a/tests/workflows/test_hathi_limited_to_dl_compound.py b/tests/workflows/test_hathi_limited_to_dl_compound.py
index d6fd13ddc..7cae145d2 100644
--- a/tests/workflows/test_hathi_limited_to_dl_compound.py
+++ b/tests/workflows/test_hathi_limited_to_dl_compound.py
@@ -143,7 +143,7 @@ def test_transform_is_called(self):
 @pytest.mark.parametrize("index,label", options)
 def test_hathi_limited_to_dl_compound_has_options(index, label):
     workflow = HathiLimitedToDLWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
diff --git a/tests/workflows/test_hathiprep.py b/tests/workflows/test_hathiprep.py
index 810174acb..f01945c7a 100644
--- a/tests/workflows/test_hathiprep.py
+++ b/tests/workflows/test_hathiprep.py
@@ -14,7 +14,7 @@
 ])
 def test_workflow_options(index, label):
     workflow = workflow_hathiprep.HathiPrepWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
@@ -86,7 +86,7 @@ def mock_scandir(path):
 @pytest.fixture
 def unconfigured_workflow():
     workflow = workflow_hathiprep.HathiPrepWorkflow()
-    user_options = {i.label: i.value for i in workflow.get_user_options()}
+    user_options = {i.label: i.value for i in workflow.job_options()}
 
     return workflow, user_options
 
diff --git a/tests/workflows/test_make_jp2.py b/tests/workflows/test_make_jp2.py
index 14efea952..175e2db1e 100644
--- a/tests/workflows/test_make_jp2.py
+++ b/tests/workflows/test_make_jp2.py
@@ -12,7 +12,7 @@
 ])
 def test_make_jp2_workflow_options(index, label):
     workflow = workflow_make_jp2.MakeJp2Workflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
@@ -20,7 +20,7 @@ def test_make_jp2_workflow_options(index, label):
 @pytest.fixture
 def unconfigured_workflow():
     workflow = workflow_make_jp2.MakeJp2Workflow()
-    user_options = {i.label: i.value for i in workflow.get_user_options()}
+    user_options = {i.label: i.value for i in workflow.job_options()}
 
     return workflow, user_options
 
diff --git a/tests/workflows/test_make_workflow_make_checksum.py b/tests/workflows/test_make_workflow_make_checksum.py
index bbba89f65..f91b35b23 100644
--- a/tests/workflows/test_make_workflow_make_checksum.py
+++ b/tests/workflows/test_make_workflow_make_checksum.py
@@ -17,7 +17,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_discover_task_metadata(
@@ -139,7 +139,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_discover_task_metadata(
@@ -276,7 +276,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_discover_task_metadata(
diff --git a/tests/workflows/test_medua_preingest_curation.py b/tests/workflows/test_medua_preingest_curation.py
index bd9285d4b..3b2350837 100644
--- a/tests/workflows/test_medua_preingest_curation.py
+++ b/tests/workflows/test_medua_preingest_curation.py
@@ -14,7 +14,7 @@ class TestMedusaPreingestCuration:
     @pytest.fixture
     def default_args(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     @pytest.fixture
@@ -157,7 +157,7 @@ def test_generate_report_mentions_file_name(self, workflow, default_args):
 def default_user_args():
     workflow = workflow_medusa_preingest.MedusaPreingestCuration()
     return {
-        data.label: data.value for data in workflow.get_user_options()
+        data.label: data.value for data in workflow.job_options()
     }
 
 
@@ -215,6 +215,7 @@ def test_description(self):
                 "Locate and delete dot underscore files": True,
                 "Locate and delete .DS_Store files": True,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
         assert search_path in task.task_description()
@@ -243,6 +244,7 @@ def test_work_calls_locate_results(self, monkeypatch):
                 "Locate and delete dot underscore files": True,
                 "Locate and delete .DS_Store files": True,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
         locate_results = Mock()
@@ -260,6 +262,7 @@ def test_locate_results_throws_file_not_found_if_not_exists(self):
                 "Locate and delete dot underscore files": True,
                 "Locate and delete .DS_Store files": True,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
         with pytest.raises(FileNotFoundError):
@@ -274,6 +277,7 @@ def test_locate_results_calls_locate_folders(self, monkeypatch):
                 "Locate and delete dot underscore files": True,
                 "Locate and delete .DS_Store files": True,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
         monkeypatch.setattr(
@@ -329,6 +333,7 @@ def test_locate_offending_subdirectories_calls_find_capture_one_data(
                 "Locate and delete dot underscore files": True,
                 "Locate and delete .DS_Store files": True,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
 
@@ -343,8 +348,11 @@ def test_locate_offending_subdirectories_calls_find_capture_one_data(
     def offending_files(self):
         def _make_mock_offending_files(search_path):
             ds_store_file = Mock(
+                os.DirEntry,
+                name=".DS_Store",
                 path=os.path.join(search_path, ".DS_Store"),
-                is_file=Mock(return_value=True)
+                is_file=Mock(return_value=True),
+                is_dir=Mock(return_value=False)
             )
             ds_store_file.name = ".DS_Store"
 
@@ -389,6 +397,7 @@ def test_locate_offending_files(
                 "Locate and delete dot underscore files": underscore,
                 "Locate and delete .DS_Store files": ds_store,
                 "Locate and delete Capture One files": True,
+                "Locate and delete Thumbs.db files": True,
             }
         )
 
diff --git a/tests/workflows/test_ocr.py b/tests/workflows/test_ocr.py
index 71336528c..85cbe64a8 100644
--- a/tests/workflows/test_ocr.py
+++ b/tests/workflows/test_ocr.py
@@ -10,10 +10,6 @@
 from uiucprescon.ocr import reader, tesseractwrap
 
 
-def test_no_config():
-    with pytest.raises(MissingConfiguration):
-        workflow_ocr.OCRWorkflow()
-
 
 def test_discover_task_metadata_raises_with_no_tessdata(monkeypatch):
     user_options = {"tessdata": "/some/path"}
@@ -27,9 +23,9 @@ def test_discover_task_metadata_raises_with_no_tessdata(monkeypatch):
 
 
 def test_discover_task_metadata(monkeypatch, tmpdir):
-    user_options = {"tessdata": "/some/path"}
-    monkeypatch.setattr(os.path, "exists", lambda args: True)
-    workflow = workflow_ocr.OCRWorkflow(global_settings=user_options)
+    # user_options = {"tessdata": "/some/path"}
+    # monkeypatch.setattr(os.path, "exists", lambda args: True)
+    workflow = workflow_ocr.OCRWorkflow()
     tessdata_dir = tmpdir / "tessdata"
     image_dir = tmpdir / "images"
     tessdata_dir.ensure_dir()
@@ -45,10 +41,13 @@ def test_discover_task_metadata(monkeypatch, tmpdir):
             data=[(image_dir / "dummy.jp2").strpath]
         )
     ]
-
-    new_tasks = workflow.discover_task_metadata(
-        initial_results, None, **user_options
-    )
+    options_backend = Mock(get=lambda key: {"Tesseract data file location": "/some/file/path"}.get(key))
+    workflow.set_options_backend(options_backend)
+    with monkeypatch.context() as ctx:
+        ctx.setattr(os.path, "exists", lambda path: path == "/some/file/path")
+        new_tasks = workflow.discover_task_metadata(
+            initial_results, None, **user_options
+        )
 
     assert len(new_tasks) == 1
     new_task = new_tasks[0]
@@ -113,7 +112,7 @@ def workflow(self, monkeypatch):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_validate_user_options_valid(
@@ -166,7 +165,7 @@ def test_validate_user_options_invalid(
         with pytest.raises(ValueError):
             workflow.validate_user_options(**user_options)
 
-    def test_discover_task_metadata(self, workflow, default_options):
+    def test_discover_task_metadata(self, workflow, default_options, monkeypatch):
         user_options = default_options.copy()
         user_options["Language"] = "English"
         user_options["Path"] = os.path.join("some", "path")
@@ -177,11 +176,19 @@ def test_discover_task_metadata(self, workflow, default_options):
             ])
         ]
         additional_data = {}
-        tasks_generated = workflow.discover_task_metadata(
-            initial_results=initial_results,
-            additional_data=additional_data,
-            **user_options
-        )
+        options_backend = Mock(get=lambda key: {"Tesseract data file location": "/some/file/path"}.get(key))
+        workflow.set_options_backend(options_backend)
+        with monkeypatch.context() as ctx:
+            def exists(path):
+                result = path == "/some/file/path"
+                return result
+            ctx.setattr(os.path, "exists", exists)
+            # ctx.setattr(os.path, "exists", lambda path: path == "/some/file/path")
+            tasks_generated = workflow.discover_task_metadata(
+                initial_results=initial_results,
+                additional_data=additional_data,
+                **user_options
+            )
         assert len(tasks_generated) == 1
         task = tasks_generated[0]
         assert task['lang_code'] == "eng" and \
@@ -198,7 +205,7 @@ def test_create_new_task(self, workflow, monkeypatch):
                 os.path.join(
                     "some",
                     "path",
-                )
+                ),
         }
         task_builder = Mock()
         GenerateOCRFileTask = Mock()
@@ -206,6 +213,8 @@ def test_create_new_task(self, workflow, monkeypatch):
         monkeypatch.setattr(workflow_ocr, "GenerateOCRFileTask",
                             GenerateOCRFileTask)
         #
+        options_backend = Mock(get=lambda key: {"Tesseract data file location": "/some/file/path"}.get(key))
+        workflow.set_options_backend(options_backend)
         workflow.create_new_task(task_builder, **job_args)
         assert task_builder.add_subtask.called is True
         GenerateOCRFileTask.assert_called_with(
diff --git a/tests/workflows/test_validate_metadata.py b/tests/workflows/test_validate_metadata.py
index 0537e9570..e521468d0 100644
--- a/tests/workflows/test_validate_metadata.py
+++ b/tests/workflows/test_validate_metadata.py
@@ -17,7 +17,7 @@
 @pytest.mark.parametrize("index,label", options)
 def test_validate_metadata_workflow_has_options(index, label):
     workflow = workflow_validate_metadata.ValidateMetadataWorkflow()
-    user_options = workflow.get_user_options()
+    user_options = workflow.job_options()
     assert len(user_options) > 0
     assert user_options[index].label == label
 
@@ -30,7 +30,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
 
diff --git a/tests/workflows/test_verify_checksums.py b/tests/workflows/test_verify_checksums.py
index 9d0a22526..1d55f5f94 100644
--- a/tests/workflows/test_verify_checksums.py
+++ b/tests/workflows/test_verify_checksums.py
@@ -41,7 +41,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip("speedwagon.frontend.qtwidgets.models")
@@ -105,7 +105,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip("speedwagon.frontend.qtwidgets.models")
@@ -184,7 +184,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip("speedwagon.frontend.qtwidgets.models")
@@ -339,7 +339,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip("speedwagon.frontend.qtwidgets.models")
diff --git a/tests/workflows/test_workflow_capture_one_to_hathi.py b/tests/workflows/test_workflow_capture_one_to_hathi.py
index 14abe33c4..7ce4efc44 100644
--- a/tests/workflows/test_workflow_capture_one_to_hathi.py
+++ b/tests/workflows/test_workflow_capture_one_to_hathi.py
@@ -16,7 +16,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip("speedwagon.frontend.qtwidgets.models")
diff --git a/tests/workflows/test_workflow_convertTifftoHathiTrustjp2.py b/tests/workflows/test_workflow_convertTifftoHathiTrustjp2.py
index cacef8973..85815d89b 100644
--- a/tests/workflows/test_workflow_convertTifftoHathiTrustjp2.py
+++ b/tests/workflows/test_workflow_convertTifftoHathiTrustjp2.py
@@ -19,7 +19,7 @@ def default_options(self, workflow):
         # a = models.ToolOptionsModel4(
         #     workflow.get_user_options()
         # ).get()
-        return {data.label: data.value for data in workflow.get_user_options()}
+        return {data.label: data.value for data in workflow.job_options()}
 
     def test_discover_task_metadata(
             self,
diff --git a/tests/workflows/test_workflow_validate_hathi_metadata.py b/tests/workflows/test_workflow_validate_hathi_metadata.py
index 8e97a2ca2..05bb9c2fd 100644
--- a/tests/workflows/test_workflow_validate_hathi_metadata.py
+++ b/tests/workflows/test_workflow_validate_hathi_metadata.py
@@ -13,7 +13,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
         # models = pytest.importorskip('speedwagon.frontend.qtwidgets.models')
diff --git a/tests/workflows/test_workflow_zip_packages.py b/tests/workflows/test_workflow_zip_packages.py
index b1d4cf01e..156b6c051 100644
--- a/tests/workflows/test_workflow_zip_packages.py
+++ b/tests/workflows/test_workflow_zip_packages.py
@@ -15,7 +15,7 @@ def workflow(self):
     @pytest.fixture
     def default_options(self, workflow):
         return {
-            data.label: data.value for data in workflow.get_user_options()
+            data.label: data.value for data in workflow.job_options()
         }
 
     def test_validate_user_options_valid(