diff --git a/dev-tools/idea/.idea/ant.xml b/dev-tools/idea/.idea/ant.xml
index 229d83203c6..d3f96556df8 100644
--- a/dev-tools/idea/.idea/ant.xml
+++ b/dev-tools/idea/.idea/ant.xml
@@ -24,6 +24,7 @@
     <buildFile url="file://$PROJECT_DIR$/lucene/grouping/build.xml" />
     <buildFile url="file://$PROJECT_DIR$/lucene/highlighter/build.xml" />
     <buildFile url="file://$PROJECT_DIR$/lucene/join/build.xml" />
+    <buildFile url="file://$PROJECT_DIR$/lucene/luke/build.xml" />
     <buildFile url="file://$PROJECT_DIR$/lucene/memory/build.xml" />
     <buildFile url="file://$PROJECT_DIR$/lucene/misc/build.xml" />
     <buildFile url="file://$PROJECT_DIR$/lucene/queries/build.xml" />
diff --git a/dev-tools/idea/.idea/modules.xml b/dev-tools/idea/.idea/modules.xml
index 65b57fb03d5..4974f19668e 100644
--- a/dev-tools/idea/.idea/modules.xml
+++ b/dev-tools/idea/.idea/modules.xml
@@ -30,6 +30,7 @@
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/grouping/grouping.iml" />
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/highlighter/highlighter.iml" />
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/join/join.iml" />
+      <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/luke/luke.iml" />
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/memory/memory.iml" />
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/misc/misc.iml" />
       <module group="Lucene/Other" filepath="$PROJECT_DIR$/lucene/queries/queries.iml" />
diff --git a/dev-tools/idea/.idea/workspace.xml b/dev-tools/idea/.idea/workspace.xml
index 6a1fd0ad879..bbc271ee28c 100644
--- a/dev-tools/idea/.idea/workspace.xml
+++ b/dev-tools/idea/.idea/workspace.xml
@@ -148,6 +148,14 @@
       <option name="TEST_SEARCH_SCOPE"><value defaultName="singleModule" /></option>
       <patterns><pattern testClass=".*\.Test[^.]*|.*\.[^.]*Test" /></patterns>
     </configuration>
+    <configuration default="false" name="Module luke" type="JUnit" factoryName="JUnit">
+      <module name="luke" />
+      <option name="TEST_OBJECT" value="pattern" />
+      <option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$/idea-build/lucene/luke" />
+      <option name="VM_PARAMETERS" value="-ea -DtempDir=temp" />
+      <option name="TEST_SEARCH_SCOPE"><value defaultName="singleModule" /></option>
+      <patterns><pattern testClass=".*\.Test[^.]*|.*\.[^.]*Test" /></patterns>
+    </configuration>
     <configuration default="false" name="Module memory" type="JUnit" factoryName="JUnit">
       <module name="memory" />
       <option name="TEST_OBJECT" value="pattern" />
diff --git a/dev-tools/idea/lucene/luke/luke.iml b/dev-tools/idea/lucene/luke/luke.iml
new file mode 100644
index 00000000000..9bd08ef4ab1
--- /dev/null
+++ b/dev-tools/idea/lucene/luke/luke.iml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="false">
+    <output url="file://$MODULE_DIR$/../../idea-build/lucene/luke/classes/java" />
+    <output-test url="file://$MODULE_DIR$/../../idea-build/lucene/luke/classes/test" />
+    <exclude-output />
+    <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$/src/java" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/src/resources" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/src/test" isTestSource="true" />
+      <excludeFolder url="file://$MODULE_DIR$/work" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+    <orderEntry type="module-library">
+      <library>
+        <CLASSES>
+          <root url="file://$MODULE_DIR$/lib" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES />
+        <jarDirectory url="file://$MODULE_DIR$/lib" recursive="false" />
+      </library>
+    </orderEntry>
+    <orderEntry type="library" scope="TEST" name="JUnit" level="project" />
+    <orderEntry type="module" scope="TEST" module-name="lucene-test-framework" />
+    <orderEntry type="module" module-name="lucene-core" />
+    <orderEntry type="module" module-name="analysis-common" />
+    <orderEntry type="module" module-name="misc" />
+    <orderEntry type="module" module-name="queries" />
+    <orderEntry type="module" module-name="queryparser" />
+  </component>
+</module>
diff --git a/lucene/build.xml b/lucene/build.xml
index 3c1439c7e26..e3cf905c971 100644
--- a/lucene/build.xml
+++ b/lucene/build.xml
@@ -287,6 +287,7 @@
       <zipfileset prefix="lucene-${version}" dir="${build.dir}">
         <patternset refid="binary.build.dist.patterns"/>
       </zipfileset>
+      <zipfileset prefix="lucene-${version}" dir="${build.dir}" includes="**/*.sh,**/*.bat" filemode="755"/>
     </zip>
     <make-checksums file="${dist.dir}/lucene-${version}.zip"/>
   </target>
@@ -310,6 +311,7 @@
       <tarfileset prefix="lucene-${version}" dir="${build.dir}">
         <patternset refid="binary.build.dist.patterns"/>
       </tarfileset>
+      <tarfileset prefix="lucene-${version}" dir="${build.dir}" includes="**/*.sh,**/*.bat" filemode="755"/>
     </tar>
     <make-checksums file="${dist.dir}/lucene-${version}.tgz"/>
   </target>
diff --git a/lucene/ivy-ignore-conflicts.properties b/lucene/ivy-ignore-conflicts.properties
index 6300bdf6d6f..df3a2e5a43b 100644
--- a/lucene/ivy-ignore-conflicts.properties
+++ b/lucene/ivy-ignore-conflicts.properties
@@ -10,4 +10,5 @@
 # trigger a conflict) when the ant check-lib-versions target is run.
 
 /com.google.guava/guava = 16.0.1
-/org.ow2.asm/asm = 5.0_BETA
\ No newline at end of file
+/org.ow2.asm/asm = 5.0_BETA
+
diff --git a/lucene/licenses/elegant-icon-font-LICENSE-MIT.txt b/lucene/licenses/elegant-icon-font-LICENSE-MIT.txt
new file mode 100644
index 00000000000..effefee5f0c
--- /dev/null
+++ b/lucene/licenses/elegant-icon-font-LICENSE-MIT.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) <2013> <Elegant Themes, Inc.>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/lucene/licenses/elegant-icon-font-NOTICE.txt b/lucene/licenses/elegant-icon-font-NOTICE.txt
new file mode 100644
index 00000000000..ea97d9b601c
--- /dev/null
+++ b/lucene/licenses/elegant-icon-font-NOTICE.txt
@@ -0,0 +1,3 @@
+The Elegant Icon Font web page: https://www.elegantthemes.com/blog/resources/elegant-icon-font
+
+These icons are dual licensed under the GPL 2.0 and MIT, and are completely free to use.
diff --git a/lucene/licenses/log4j-LICENSE-ASL.txt b/lucene/licenses/log4j-LICENSE-ASL.txt
new file mode 100644
index 00000000000..d6456956733
--- /dev/null
+++ b/lucene/licenses/log4j-LICENSE-ASL.txt
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/lucene/licenses/log4j-NOTICE.txt b/lucene/licenses/log4j-NOTICE.txt
new file mode 100644
index 00000000000..d697542317c
--- /dev/null
+++ b/lucene/licenses/log4j-NOTICE.txt
@@ -0,0 +1,5 @@
+Apache log4j
+Copyright 2010 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/lucene/licenses/log4j-api-2.11.2.jar.sha1 b/lucene/licenses/log4j-api-2.11.2.jar.sha1
new file mode 100644
index 00000000000..0cdea100b72
--- /dev/null
+++ b/lucene/licenses/log4j-api-2.11.2.jar.sha1
@@ -0,0 +1 @@
+f5e9a2ffca496057d6891a3de65128efc636e26e
diff --git a/lucene/licenses/log4j-api-LICENSE-ASL.txt b/lucene/licenses/log4j-api-LICENSE-ASL.txt
new file mode 100644
index 00000000000..f49a4e16e68
--- /dev/null
+++ b/lucene/licenses/log4j-api-LICENSE-ASL.txt
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
\ No newline at end of file
diff --git a/lucene/licenses/log4j-api-NOTICE.txt b/lucene/licenses/log4j-api-NOTICE.txt
new file mode 100644
index 00000000000..ebba5ac0018
--- /dev/null
+++ b/lucene/licenses/log4j-api-NOTICE.txt
@@ -0,0 +1,17 @@
+Apache Log4j
+Copyright 1999-2017 Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+ResolverUtil.java
+Copyright 2005-2006 Tim Fennell
+
+Dumbster SMTP test server
+Copyright 2004 Jason Paul Kitchen
+
+TypeUtil.java
+Copyright 2002-2012 Ramnivas Laddad, Juergen Hoeller, Chris Beams
+
+picocli (http://picocli.info)
+Copyright 2017 Remko Popma
\ No newline at end of file
diff --git a/lucene/licenses/log4j-core-2.11.2.jar.sha1 b/lucene/licenses/log4j-core-2.11.2.jar.sha1
new file mode 100644
index 00000000000..ec2acae4df7
--- /dev/null
+++ b/lucene/licenses/log4j-core-2.11.2.jar.sha1
@@ -0,0 +1 @@
+6c2fb3f5b7cd27504726aef1b674b542a0c9cf53
diff --git a/lucene/licenses/log4j-core-LICENSE-ASL.txt b/lucene/licenses/log4j-core-LICENSE-ASL.txt
new file mode 100644
index 00000000000..f49a4e16e68
--- /dev/null
+++ b/lucene/licenses/log4j-core-LICENSE-ASL.txt
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
\ No newline at end of file
diff --git a/lucene/licenses/log4j-core-NOTICE.txt b/lucene/licenses/log4j-core-NOTICE.txt
new file mode 100644
index 00000000000..ebba5ac0018
--- /dev/null
+++ b/lucene/licenses/log4j-core-NOTICE.txt
@@ -0,0 +1,17 @@
+Apache Log4j
+Copyright 1999-2017 Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+ResolverUtil.java
+Copyright 2005-2006 Tim Fennell
+
+Dumbster SMTP test server
+Copyright 2004 Jason Paul Kitchen
+
+TypeUtil.java
+Copyright 2002-2012 Ramnivas Laddad, Juergen Hoeller, Chris Beams
+
+picocli (http://picocli.info)
+Copyright 2017 Remko Popma
\ No newline at end of file
diff --git a/lucene/luke/bin/luke.bat b/lucene/luke/bin/luke.bat
new file mode 100644
index 00000000000..4d83d8bf319
--- /dev/null
+++ b/lucene/luke/bin/luke.bat
@@ -0,0 +1,13 @@
+@echo off
+@setlocal enabledelayedexpansion
+
+cd /d %~dp0
+
+set JAVA_OPTIONS=%JAVA_OPTIONS% -Xmx1024m -Xms512m -XX:MaxMetaspaceSize=256m
+
+set CLASSPATHS=.\*;.\lib\*;..\core\*;..\codecs\*;..\backward-codecs\*;..\queries\*;..\queryparser\*;..\suggest\*;..\misc\*
+for /d %%A in (..\analysis\*) do (
+    set "CLASSPATHS=!CLASSPATHS!;%%A\*;%%A\lib\*"
+)
+
+start javaw -cp %CLASSPATHS% %JAVA_OPTIONS% org.apache.lucene.luke.app.desktop.LukeMain
diff --git a/lucene/luke/bin/luke.sh b/lucene/luke/bin/luke.sh
new file mode 100755
index 00000000000..7c7d9191056
--- /dev/null
+++ b/lucene/luke/bin/luke.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+LUKE_HOME=$(cd $(dirname $0) && pwd)
+cd ${LUKE_HOME}
+
+JAVA_OPTIONS="${JAVA_OPTIONS} -Xmx1024m -Xms512m -XX:MaxMetaspaceSize=256m"
+
+CLASSPATHS="./*:./lib/*:../core/*:../codecs/*:../backward-codecs/*:../queries/*:../queryparser/*:../suggest/*:../misc/*"
+for dir in `ls ../analysis`; do
+  CLASSPATHS="${CLASSPATHS}:../analysis/${dir}/*:../analysis/${dir}/lib/*"
+done
+
+LOG_DIR=${HOME}/.luke.d/
+ if [[ ! -d ${LOG_DIR} ]]; then
+   mkdir ${LOG_DIR}
+ fi
+
+nohup java -cp ${CLASSPATHS} ${JAVA_OPTIONS} org.apache.lucene.luke.app.desktop.LukeMain > ${LOG_DIR}/luke_out.log 2>&1 &
diff --git a/lucene/luke/build.xml b/lucene/luke/build.xml
new file mode 100644
index 00000000000..9064d26e488
--- /dev/null
+++ b/lucene/luke/build.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0"?>
+
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You under the Apache License, Version 2.0
+    the "License"); you may not use this file except in compliance with
+    the License.  You may obtain a copy of the License at
+ 
+        http://www.apache.org/licenses/LICENSE-2.0
+ 
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ -->
+
+<project name="luke" default="default">
+
+  <description>
+    Luke - Lucene Toolbox
+  </description>
+
+  <!-- use full Java SE API (project default 'compact2' does not include Swing) -->
+  <property name="javac.profile.args" value=""/>
+
+  <import file="../module-build.xml"/>
+
+  <target name="init" depends="module-build.init,jar-lucene-core"/>
+
+  <path id="classpath">
+    <pathelement path="${lucene-core.jar}"/>
+    <pathelement path="${codecs.jar}"/>
+    <pathelement path="${backward-codecs.jar}"/>
+    <pathelement path="${analyzers-common.jar}"/>
+    <pathelement path="${misc.jar}"/>
+    <pathelement path="${queryparser.jar}"/>
+    <pathelement path="${queries.jar}"/>
+    <fileset dir="lib"/>
+    <path refid="base.classpath"/>
+  </path>
+
+  <target name="javadocs" depends="compile-core,javadocs-lucene-core,javadocs-analyzers-common,check-javadocs-uptodate"
+          unless="javadocs-uptodate-${name}">
+    <invoke-module-javadoc>
+      <links>
+        <link href="../analyzers-common"/>
+      </links>
+    </invoke-module-javadoc>
+  </target>
+
+  <target name="build-artifacts-and-tests" depends="jar, compile-test">
+    <!-- copy start scripts -->
+    <copy todir="${build.dir}">
+      <fileset dir="${common.dir}/luke/bin">
+        <include name="**/*.sh"/>
+        <include name="**/*.bat"/>
+      </fileset>
+    </copy>
+  </target>
+
+  <!-- launch Luke -->
+  <target name="run" depends="compile-core" description="Launch Luke GUI">
+    <java classname="org.apache.lucene.luke.app.desktop.LukeMain"
+          classpath="${build.dir}/classes/java"
+          fork="true"
+          maxmemory="512m">
+      <classpath refid="classpath"/>
+    </java>
+  </target>
+  
+  <target name="compile-core"
+          depends="jar-codecs,jar-backward-codecs,jar-analyzers-common,jar-misc,jar-queryparser,jar-queries,jar-misc,common.compile-core"/>
+
+</project>
diff --git a/lucene/luke/ivy.xml b/lucene/luke/ivy.xml
new file mode 100644
index 00000000000..88d9d8c63b6
--- /dev/null
+++ b/lucene/luke/ivy.xml
@@ -0,0 +1,34 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.    
+-->
+<ivy-module version="2.0">
+  <info organisation="org.apache.lucene" module="luke"/>
+
+  <configurations defaultconfmapping="compile->default;logging->default">
+    <conf name="compile" transitive="false"/>
+    <conf name="logging" transitive="false"/>
+  </configurations>
+
+  <dependencies>
+    <dependency org="org.apache.logging.log4j" name="log4j-api" rev="${/org.apache.logging.log4j/log4j-api}"
+                conf="logging"/>
+    <dependency org="org.apache.logging.log4j" name="log4j-core" rev="${/org.apache.logging.log4j/log4j-core}"
+                conf="logging"/>
+    <exclude org="*" ext="*" matcher="regexp" type="${ivy.exclude.types}"/>
+  </dependencies>
+</ivy-module>
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/AbstractHandler.java b/lucene/luke/src/java/org/apache/lucene/luke/app/AbstractHandler.java
new file mode 100644
index 00000000000..ab967a8d149
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/AbstractHandler.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+/** Abstract handler class */
+public abstract class AbstractHandler<T extends Observer> {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private List<T> observers = new ArrayList<>();
+
+  public void addObserver(T observer) {
+    observers.add(observer);
+    log.debug("{} registered.", observer.getClass().getName());
+  }
+
+  void notifyObservers() {
+    for (T observer : observers) {
+      notifyOne(observer);
+    }
+  }
+
+  protected abstract void notifyOne(T observer);
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryHandler.java b/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryHandler.java
new file mode 100644
index 00000000000..ec4e7e5d23a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryHandler.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.store.Directory;
+
+/** Directory open/close handler */
+public final class DirectoryHandler extends AbstractHandler<DirectoryObserver> {
+
+  private static final DirectoryHandler instance = new DirectoryHandler();
+
+  private LukeStateImpl state;
+
+  public static DirectoryHandler getInstance() {
+    return instance;
+  }
+
+  @Override
+  protected void notifyOne(DirectoryObserver observer) {
+    if (state.closed) {
+      observer.closeDirectory();
+    } else {
+      observer.openDirectory(state);
+    }
+  }
+
+  public boolean directoryOpened() {
+    return state != null && !state.closed;
+  }
+
+  public void open(String indexPath, String dirImpl) {
+    Objects.requireNonNull(indexPath);
+
+    if (directoryOpened()) {
+      close();
+    }
+
+    Directory dir;
+    try {
+      dir = IndexUtils.openDirectory(indexPath, dirImpl);
+    } catch (IOException e) {
+      throw new LukeException(MessageUtils.getLocalizedMessage("openindex.message.index_path_invalid", indexPath), e);
+    }
+
+    state = new LukeStateImpl();
+    state.indexPath = indexPath;
+    state.dirImpl = dirImpl;
+    state.dir = dir;
+
+    notifyObservers();
+  }
+
+  public void close() {
+    if (state == null) {
+      return;
+    }
+
+    IndexUtils.close(state.dir);
+
+    state.closed = true;
+    notifyObservers();
+  }
+
+  public LukeState getState() {
+    return state;
+  }
+
+  private static class LukeStateImpl implements LukeState {
+    private boolean closed = false;
+
+    private String indexPath;
+    private String dirImpl;
+    private Directory dir;
+
+    @Override
+    public String getIndexPath() {
+      return indexPath;
+    }
+
+    @Override
+    public String getDirImpl() {
+      return dirImpl;
+    }
+
+    @Override
+    public Directory getDirectory() {
+      return dir;
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryObserver.java b/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryObserver.java
new file mode 100644
index 00000000000..64371150f87
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/DirectoryObserver.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+/** Directory open/close observer */
+public interface DirectoryObserver extends Observer {
+
+  void openDirectory(LukeState state);
+
+  void closeDirectory();
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/IndexHandler.java b/lucene/luke/src/java/org/apache/lucene/luke/app/IndexHandler.java
new file mode 100644
index 00000000000..17e407043e1
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/IndexHandler.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+import java.lang.invoke.MethodHandles;
+import java.util.Objects;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+/** Index open/close handler */
+public final class IndexHandler extends AbstractHandler<IndexObserver> {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final IndexHandler instance = new IndexHandler();
+
+  private LukeStateImpl state;
+
+  public static IndexHandler getInstance() {
+    return instance;
+  }
+
+  @Override
+  protected void notifyOne(IndexObserver observer) {
+    if (state.closed) {
+      observer.closeIndex();
+    } else {
+      observer.openIndex(state);
+    }
+  }
+
+  public boolean indexOpened() {
+    return state != null && !state.closed;
+  }
+
+  public void open(String indexPath, String dirImpl) {
+    open(indexPath, dirImpl, false, false, false);
+  }
+
+  public void open(String indexPath, String dirImpl, boolean readOnly, boolean useCompound, boolean keepAllCommits) {
+    Objects.requireNonNull(indexPath);
+
+    if (indexOpened()) {
+      close();
+    }
+
+    IndexReader reader;
+    try {
+      reader = IndexUtils.openIndex(indexPath, dirImpl);
+    } catch (Exception e) {
+      log.error(e.getMessage(), e);
+      throw new LukeException(MessageUtils.getLocalizedMessage("openindex.message.index_path_invalid", indexPath), e);
+    }
+
+    state = new LukeStateImpl();
+    state.indexPath = indexPath;
+    state.reader = reader;
+    state.dirImpl = dirImpl;
+    state.readOnly = readOnly;
+    state.useCompound = useCompound;
+    state.keepAllCommits = keepAllCommits;
+
+    notifyObservers();
+  }
+
+  public void close() {
+    if (state == null) {
+      return;
+    }
+
+    IndexUtils.close(state.reader);
+
+    state.closed = true;
+    notifyObservers();
+  }
+
+  public void reOpen() {
+    close();
+    open(state.getIndexPath(), state.getDirImpl(), state.readOnly(), state.useCompound(), state.keepAllCommits());
+  }
+
+  public LukeState getState() {
+    return state;
+  }
+
+  private static class LukeStateImpl implements LukeState {
+
+    private boolean closed = false;
+
+    private String indexPath;
+    private IndexReader reader;
+    private String dirImpl;
+    private boolean readOnly;
+    private boolean useCompound;
+    private boolean keepAllCommits;
+
+    @Override
+    public String getIndexPath() {
+      return indexPath;
+    }
+
+    @Override
+    public IndexReader getIndexReader() {
+      return reader;
+    }
+
+    @Override
+    public String getDirImpl() {
+      return dirImpl;
+    }
+
+    @Override
+    public boolean readOnly() {
+      return readOnly;
+    }
+
+    @Override
+    public boolean useCompound() {
+      return useCompound;
+    }
+
+    @Override
+    public boolean keepAllCommits() {
+      return keepAllCommits;
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/IndexObserver.java b/lucene/luke/src/java/org/apache/lucene/luke/app/IndexObserver.java
new file mode 100644
index 00000000000..599b1090c4d
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/IndexObserver.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+/** Index open/close observer */
+public interface IndexObserver extends Observer {
+
+  void openIndex(LukeState state);
+
+  void closeIndex();
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/LukeState.java b/lucene/luke/src/java/org/apache/lucene/luke/app/LukeState.java
new file mode 100644
index 00000000000..33ca829bca5
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/LukeState.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.store.Directory;
+
+/**
+ * Holder for current index/directory.
+ */
+public interface LukeState {
+
+  String getIndexPath();
+
+  String getDirImpl();
+
+  default Directory getDirectory() {
+    throw new UnsupportedOperationException();
+  }
+
+  default IndexReader getIndexReader() {
+    throw new UnsupportedOperationException();
+  }
+
+  default boolean readOnly() {
+    throw new UnsupportedOperationException();
+  }
+
+  default boolean useCompound() {
+    throw new UnsupportedOperationException();
+  }
+
+  default boolean keepAllCommits() {
+    throw new UnsupportedOperationException();
+  }
+
+  default boolean hasDirectoryReader() {
+    return getIndexReader() instanceof DirectoryReader;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/Observer.java b/lucene/luke/src/java/org/apache/lucene/luke/app/Observer.java
new file mode 100644
index 00000000000..290865b8986
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/Observer.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app;
+
+/** Marker interface for observers */
+public interface Observer {
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/LukeMain.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/LukeMain.java
new file mode 100644
index 00000000000..fae52f29abd
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/LukeMain.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop;
+
+import javax.swing.JFrame;
+import javax.swing.UIManager;
+import java.awt.GraphicsEnvironment;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.FileSystems;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.app.desktop.components.LukeWindowProvider;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.OpenIndexDialogFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+import static org.apache.lucene.luke.app.desktop.util.ExceptionHandler.handle;
+
+/** Entry class for desktop Luke */
+public class LukeMain {
+
+  public static final String LOG_FILE = System.getProperty("user.home") +
+      FileSystems.getDefault().getSeparator() + ".luke.d" +
+      FileSystems.getDefault().getSeparator() + "luke.log";
+
+  static {
+    LoggerFactory.initGuiLogging(LOG_FILE);
+  }
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  
+  private static JFrame frame;
+
+  public static JFrame getOwnerFrame() {
+    return frame;
+  }
+
+  private static void createAndShowGUI() {
+    // uncaught error handler
+    MessageBroker messageBroker = MessageBroker.getInstance();
+    Thread.setDefaultUncaughtExceptionHandler((thread, cause) ->
+        handle(cause, messageBroker)
+    );
+
+    try {
+      frame = new LukeWindowProvider().get();
+      frame.setLocation(200, 100);
+      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+      frame.pack();
+      frame.setVisible(true);
+
+      // show open index dialog
+      OpenIndexDialogFactory openIndexDialogFactory = OpenIndexDialogFactory.getInstance();
+      new DialogOpener<>(openIndexDialogFactory).open(MessageUtils.getLocalizedMessage("openindex.dialog.title"), 600, 420,
+          (factory) -> {
+          });
+    } catch (IOException e) {
+      messageBroker.showUnknownErrorMessage();
+      log.error("Cannot initialize components.", e);
+    }
+  }
+
+  public static void main(String[] args) throws Exception {
+    String lookAndFeelClassName = UIManager.getSystemLookAndFeelClassName();
+    if (!lookAndFeelClassName.contains("AquaLookAndFeel") && !lookAndFeelClassName.contains("PlasticXPLookAndFeel")) {
+      // may be running on linux platform
+      lookAndFeelClassName = "javax.swing.plaf.metal.MetalLookAndFeel";
+    }
+    UIManager.setLookAndFeel(lookAndFeelClassName);
+
+    GraphicsEnvironment genv = GraphicsEnvironment.getLocalGraphicsEnvironment();
+    genv.registerFont(FontUtils.createElegantIconFont());
+
+    javax.swing.SwingUtilities.invokeLater(LukeMain::createAndShowGUI);
+
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/MessageBroker.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/MessageBroker.java
new file mode 100644
index 00000000000..9609a2f56ef
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/MessageBroker.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Message broker */
+public class MessageBroker {
+
+  private static final MessageBroker instance = new MessageBroker();
+
+  private List<MessageReceiver> receivers = new ArrayList<>();
+
+  public static MessageBroker getInstance() {
+    return instance;
+  }
+
+  public void registerReceiver(MessageReceiver receiver) {
+    receivers.add(receiver);
+  }
+
+  public void showStatusMessage(String message) {
+    for (MessageReceiver receiver : receivers) {
+      receiver.showStatusMessage(message);
+    }
+  }
+
+  public void showUnknownErrorMessage() {
+    for (MessageReceiver receiver : receivers) {
+      receiver.showUnknownErrorMessage();
+    }
+  }
+
+  public void clearStatusMessage() {
+    for (MessageReceiver receiver : receivers) {
+      receiver.clearStatusMessage();
+    }
+  }
+
+  /** Message receiver in charge of rendering the message. */
+  public interface MessageReceiver {
+    void showStatusMessage(String message);
+
+    void showUnknownErrorMessage();
+
+    void clearStatusMessage();
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/Preferences.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/Preferences.java
new file mode 100644
index 00000000000..b0df6607403
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/Preferences.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.List;
+
+/** Preference */
+public interface Preferences {
+
+  List<String> getHistory();
+
+  void addHistory(String indexPath) throws IOException;
+
+  boolean isReadOnly();
+
+  String getDirImpl();
+
+  boolean isNoReader();
+
+  boolean isUseCompound();
+
+  boolean isKeepAllCommits();
+
+  void setIndexOpenerPrefs(boolean readOnly, String dirImpl, boolean noReader, boolean useCompound, boolean keepAllCommits) throws IOException;
+
+  ColorTheme getColorTheme();
+
+  void setColorTheme(ColorTheme theme) throws IOException;
+
+  /** color themes */
+  enum ColorTheme {
+
+    /* Gray theme */
+    GRAY(Color.decode("#e6e6e6")),
+    /* Classic theme */
+    CLASSIC(Color.decode("#ece9d0")),
+    /* Sandstone theme */
+    SANDSTONE(Color.decode("#ddd9d4")),
+    /* Navy theme */
+    NAVY(Color.decode("#e6e6ff"));
+
+    private Color backgroundColor;
+
+    ColorTheme(Color backgroundColor) {
+      this.backgroundColor = backgroundColor;
+    }
+
+    public Color getBackgroundColor() {
+      return backgroundColor;
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesFactory.java
new file mode 100644
index 00000000000..2502553297f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop;
+
+import java.io.IOException;
+
+/** Factory of {@link Preferences} */
+public class PreferencesFactory {
+
+  private static Preferences prefs;
+
+  public synchronized static Preferences getInstance() throws IOException {
+    if (prefs == null) {
+      prefs = new PreferencesImpl();
+    }
+    return prefs;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesImpl.java
new file mode 100644
index 00000000000..ebf78c5a57b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/PreferencesImpl.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop;
+
+import java.io.IOException;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.lucene.luke.app.desktop.util.inifile.IniFile;
+import org.apache.lucene.luke.app.desktop.util.inifile.SimpleIniFile;
+import org.apache.lucene.store.FSDirectory;
+
+/** Default implementation of {@link Preferences} */
+public final class PreferencesImpl implements Preferences {
+
+  private static final String CONFIG_DIR = System.getProperty("user.home") + FileSystems.getDefault().getSeparator() + ".luke.d";
+  private static final String INIT_FILE = "luke.ini";
+  private static final String HISTORY_FILE = "history";
+  private static final int MAX_HISTORY = 10;
+
+  private final IniFile ini = new SimpleIniFile();
+
+
+  private final List<String> history = new ArrayList<>();
+
+  public PreferencesImpl() throws IOException {
+    // create config dir if not exists
+    Path confDir = FileSystems.getDefault().getPath(CONFIG_DIR);
+    if (!Files.exists(confDir)) {
+      Files.createDirectory(confDir);
+    }
+
+    // load configs
+    if (Files.exists(iniFile())) {
+      ini.load(iniFile());
+    } else {
+      ini.store(iniFile());
+    }
+
+    // load history
+    Path histFile = historyFile();
+    if (Files.exists(histFile)) {
+      List<String> allHistory = Files.readAllLines(histFile);
+      history.addAll(allHistory.subList(0, Math.min(MAX_HISTORY, allHistory.size())));
+    }
+
+  }
+
+  public List<String> getHistory() {
+    return history;
+  }
+
+  @Override
+  public void addHistory(String indexPath) throws IOException {
+    if (history.indexOf(indexPath) >= 0) {
+      history.remove(indexPath);
+    }
+    history.add(0, indexPath);
+    saveHistory();
+  }
+
+  private void saveHistory() throws IOException {
+    Files.write(historyFile(), history);
+  }
+
+  private Path historyFile() {
+    return FileSystems.getDefault().getPath(CONFIG_DIR, HISTORY_FILE);
+  }
+
+  @Override
+  public ColorTheme getColorTheme() {
+    String theme = ini.getString("settings", "theme");
+    return (theme == null) ? ColorTheme.GRAY : ColorTheme.valueOf(theme);
+  }
+
+  @Override
+  public void setColorTheme(ColorTheme theme) throws IOException {
+    ini.put("settings", "theme", theme.name());
+    ini.store(iniFile());
+  }
+
+  @Override
+  public boolean isReadOnly() {
+    Boolean readOnly = ini.getBoolean("opener", "readOnly");
+    return (readOnly == null) ? false : readOnly;
+  }
+
+  @Override
+  public String getDirImpl() {
+    String dirImpl = ini.getString("opener", "dirImpl");
+    return (dirImpl == null) ? FSDirectory.class.getName() : dirImpl;
+  }
+
+  @Override
+  public boolean isNoReader() {
+    Boolean noReader = ini.getBoolean("opener", "noReader");
+    return (noReader == null) ? false : noReader;
+  }
+
+  @Override
+  public boolean isUseCompound() {
+    Boolean useCompound = ini.getBoolean("opener", "useCompound");
+    return (useCompound == null) ? false : useCompound;
+  }
+
+  @Override
+  public boolean isKeepAllCommits() {
+    Boolean keepAllCommits = ini.getBoolean("opener", "keepAllCommits");
+    return (keepAllCommits == null) ? false : keepAllCommits;
+  }
+
+  @Override
+  public void setIndexOpenerPrefs(boolean readOnly, String dirImpl, boolean noReader, boolean useCompound, boolean keepAllCommits) throws IOException {
+    ini.put("opener", "readOnly", readOnly);
+    ini.put("opener", "dirImpl", dirImpl);
+    ini.put("opener", "noReader", noReader);
+    ini.put("opener", "useCompound", useCompound);
+    ini.put("opener", "keepAllCommits", keepAllCommits);
+    ini.store(iniFile());
+  }
+
+  private Path iniFile() {
+    return FileSystems.getDefault().getPath(CONFIG_DIR, INIT_FILE);
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisPanelProvider.java
new file mode 100644
index 00000000000..70c2291bbca
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisPanelProvider.java
@@ -0,0 +1,441 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.ButtonGroup;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JScrollPane;
+import javax.swing.JSplitPane;
+import javax.swing.JTable;
+import javax.swing.JTextArea;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.custom.CustomAnalyzer;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.AnalysisChainDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.TokenAttributeDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.documents.AddDocumentDialogOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.CustomAnalyzerPanelOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.CustomAnalyzerPanelProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.PresetAnalyzerPanelOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.PresetAnalyzerPanelProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.AnalyzerTabOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.MLTTabOperator;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.analysis.Analysis;
+import org.apache.lucene.luke.models.analysis.AnalysisFactory;
+import org.apache.lucene.luke.models.analysis.CustomAnalyzerConfig;
+import org.apache.lucene.util.NamedThreadFactory;
+
+/** Provider of the Analysis panel */
+public final class AnalysisPanelProvider implements AnalysisTabOperator {
+
+  private static final String TYPE_PRESET = "preset";
+
+  private static final String TYPE_CUSTOM = "custom";
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final AnalysisChainDialogFactory analysisChainDialogFactory;
+
+  private final TokenAttributeDialogFactory tokenAttrDialogFactory;
+
+  private final MessageBroker messageBroker;
+
+  private final JPanel mainPanel = new JPanel();
+
+  private final JPanel preset;
+
+  private final JPanel custom;
+
+  private final JRadioButton presetRB = new JRadioButton();
+
+  private final JRadioButton customRB = new JRadioButton();
+
+  private final JLabel analyzerNameLbl = new JLabel();
+
+  private final JLabel showChainLbl = new JLabel();
+
+  private final JTextArea inputArea = new JTextArea();
+
+  private final JTable tokensTable = new JTable();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private List<Analysis.Token> tokens;
+
+  private Analysis analysisModel;
+
+  public AnalysisPanelProvider() throws IOException {
+    this.preset = new PresetAnalyzerPanelProvider().get();
+    this.custom = new CustomAnalyzerPanelProvider().get();
+
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.analysisChainDialogFactory = AnalysisChainDialogFactory.getInstance();
+    this.tokenAttrDialogFactory = TokenAttributeDialogFactory.getInstance();
+    this.messageBroker = MessageBroker.getInstance();
+
+    this.analysisModel = new AnalysisFactory().newInstance();
+    analysisModel.createAnalyzerFromClassName(StandardAnalyzer.class.getName());
+
+    operatorRegistry.register(AnalysisTabOperator.class, this);
+
+    operatorRegistry.get(PresetAnalyzerPanelOperator.class).ifPresent(operator -> {
+      // Scanning all Analyzer types will take time...
+      ExecutorService executorService = Executors.newFixedThreadPool(1, new NamedThreadFactory("load-preset-analyzer-types"));
+      executorService.execute(() -> {
+        operator.setPresetAnalyzers(analysisModel.getPresetAnalyzerTypes());
+        operator.setSelectedAnalyzer(analysisModel.currentAnalyzer().getClass());
+      });
+      executorService.shutdown();
+    });
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createLineBorder(Color.gray));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, initUpperPanel(), initLowerPanel());
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(320);
+    panel.add(splitPane);
+
+    return panel;
+  }
+
+  private JPanel initUpperPanel() {
+    mainPanel.setOpaque(false);
+    mainPanel.setLayout(new BorderLayout());
+    mainPanel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    mainPanel.add(initSwitcherPanel(), BorderLayout.PAGE_START);
+    mainPanel.add(preset, BorderLayout.CENTER);
+
+    return mainPanel;
+  }
+
+  private JPanel initSwitcherPanel() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    panel.setOpaque(false);
+
+    presetRB.setText(MessageUtils.getLocalizedMessage("analysis.radio.preset"));
+    presetRB.setActionCommand(TYPE_PRESET);
+    presetRB.addActionListener(listeners::toggleMainPanel);
+    presetRB.setOpaque(false);
+    presetRB.setSelected(true);
+
+    customRB.setText(MessageUtils.getLocalizedMessage("analysis.radio.custom"));
+    customRB.setActionCommand(TYPE_CUSTOM);
+    customRB.addActionListener(listeners::toggleMainPanel);
+    customRB.setOpaque(false);
+    customRB.setSelected(false);
+
+    ButtonGroup group = new ButtonGroup();
+    group.add(presetRB);
+    group.add(customRB);
+
+    panel.add(presetRB);
+    panel.add(customRB);
+
+    return panel;
+  }
+
+  private JPanel initLowerPanel() {
+    JPanel inner1 = new JPanel(new BorderLayout());
+    inner1.setOpaque(false);
+
+    JPanel analyzerName = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    analyzerName.setOpaque(false);
+    analyzerName.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.label.selected_analyzer")));
+    analyzerNameLbl.setText(analysisModel.currentAnalyzer().getClass().getName());
+    analyzerName.add(analyzerNameLbl);
+    showChainLbl.setText(MessageUtils.getLocalizedMessage("analysis.label.show_chain"));
+    showChainLbl.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        listeners.showAnalysisChain(e);
+      }
+    });
+    showChainLbl.setVisible(analysisModel.currentAnalyzer() instanceof CustomAnalyzer);
+    analyzerName.add(FontUtils.toLinkText(showChainLbl));
+    inner1.add(analyzerName, BorderLayout.PAGE_START);
+
+    JPanel input = new JPanel(new FlowLayout(FlowLayout.LEADING, 5, 2));
+    input.setOpaque(false);
+    inputArea.setRows(3);
+    inputArea.setColumns(50);
+    inputArea.setLineWrap(true);
+    inputArea.setWrapStyleWord(true);
+    inputArea.setText(MessageUtils.getLocalizedMessage("analysis.textarea.prompt"));
+    input.add(new JScrollPane(inputArea));
+
+    JButton executeBtn = new JButton(FontUtils.elegantIconHtml("&#xe007;", MessageUtils.getLocalizedMessage("analysis.button.test")));
+    executeBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    executeBtn.setMargin(new Insets(3, 3, 3, 3));
+    executeBtn.addActionListener(listeners::executeAnalysis);
+    input.add(executeBtn);
+
+    JButton clearBtn = new JButton(MessageUtils.getLocalizedMessage("button.clear"));
+    clearBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    clearBtn.setMargin(new Insets(5, 5, 5, 5));
+    clearBtn.addActionListener(e -> {
+      inputArea.setText("");
+      TableUtils.setupTable(tokensTable, ListSelectionModel.SINGLE_SELECTION, new TokensTableModel(),
+          null,
+          TokensTableModel.Column.TERM.getColumnWidth(),
+          TokensTableModel.Column.ATTR.getColumnWidth());
+    });
+    input.add(clearBtn);
+
+    inner1.add(input, BorderLayout.CENTER);
+
+    JPanel inner2 = new JPanel(new BorderLayout());
+    inner2.setOpaque(false);
+
+    JPanel hint = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    hint.setOpaque(false);
+    hint.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.hint.show_attributes")));
+    inner2.add(hint, BorderLayout.PAGE_START);
+
+
+    TableUtils.setupTable(tokensTable, ListSelectionModel.SINGLE_SELECTION, new TokensTableModel(),
+        new MouseAdapter() {
+          @Override
+          public void mouseClicked(MouseEvent e) {
+            listeners.showAttributeValues(e);
+          }
+        },
+        TokensTableModel.Column.TERM.getColumnWidth(),
+        TokensTableModel.Column.ATTR.getColumnWidth());
+    inner2.add(new JScrollPane(tokensTable), BorderLayout.CENTER);
+
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+    panel.add(inner1, BorderLayout.PAGE_START);
+    panel.add(inner2, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  // control methods
+
+  void toggleMainPanel(String command) {
+    if (command.equalsIgnoreCase(TYPE_PRESET)) {
+      mainPanel.remove(custom);
+      mainPanel.add(preset, BorderLayout.CENTER);
+
+      operatorRegistry.get(PresetAnalyzerPanelOperator.class).ifPresent(operator -> {
+        operator.setPresetAnalyzers(analysisModel.getPresetAnalyzerTypes());
+        operator.setSelectedAnalyzer(analysisModel.currentAnalyzer().getClass());
+      });
+
+    } else if (command.equalsIgnoreCase(TYPE_CUSTOM)) {
+      mainPanel.remove(preset);
+      mainPanel.add(custom, BorderLayout.CENTER);
+
+      operatorRegistry.get(CustomAnalyzerPanelOperator.class).ifPresent(operator -> {
+        operator.setAnalysisModel(analysisModel);
+        operator.resetAnalysisComponents();
+      });
+    }
+    mainPanel.setVisible(false);
+    mainPanel.setVisible(true);
+  }
+
+  void executeAnalysis() {
+    String text = inputArea.getText();
+    if (Objects.isNull(text) || text.isEmpty()) {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("analysis.message.empry_input"));
+    }
+
+    tokens = analysisModel.analyze(text);
+    tokensTable.setModel(new TokensTableModel(tokens));
+    tokensTable.setShowGrid(true);
+    tokensTable.getColumnModel().getColumn(TokensTableModel.Column.TERM.getIndex()).setPreferredWidth(TokensTableModel.Column.TERM.getColumnWidth());
+    tokensTable.getColumnModel().getColumn(TokensTableModel.Column.ATTR.getIndex()).setPreferredWidth(TokensTableModel.Column.ATTR.getColumnWidth());
+  }
+
+  void showAnalysisChainDialog() {
+    if (getCurrentAnalyzer() instanceof CustomAnalyzer) {
+      CustomAnalyzer analyzer = (CustomAnalyzer) getCurrentAnalyzer();
+      new DialogOpener<>(analysisChainDialogFactory).open("Analysis chain", 600, 320,
+          (factory) -> {
+            factory.setAnalyzer(analyzer);
+          });
+    }
+  }
+
+  void showAttributeValues(int selectedIndex) {
+    String term = tokens.get(selectedIndex).getTerm();
+    List<Analysis.TokenAttribute> attributes = tokens.get(selectedIndex).getAttributes();
+    new DialogOpener<>(tokenAttrDialogFactory).open("Token Attributes", 650, 400,
+        factory -> {
+          factory.setTerm(term);
+          factory.setAttributes(attributes);
+        });
+  }
+
+
+  @Override
+  public void setAnalyzerByType(String analyzerType) {
+    analysisModel.createAnalyzerFromClassName(analyzerType);
+    analyzerNameLbl.setText(analysisModel.currentAnalyzer().getClass().getName());
+    showChainLbl.setVisible(false);
+    operatorRegistry.get(AnalyzerTabOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+    operatorRegistry.get(MLTTabOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+    operatorRegistry.get(AddDocumentDialogOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+  }
+
+  @Override
+  public void setAnalyzerByCustomConfiguration(CustomAnalyzerConfig config) {
+    analysisModel.buildCustomAnalyzer(config);
+    analyzerNameLbl.setText(analysisModel.currentAnalyzer().getClass().getName());
+    showChainLbl.setVisible(true);
+    operatorRegistry.get(AnalyzerTabOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+    operatorRegistry.get(MLTTabOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+    operatorRegistry.get(AddDocumentDialogOperator.class).ifPresent(operator ->
+        operator.setAnalyzer(analysisModel.currentAnalyzer()));
+  }
+
+  @Override
+  public Analyzer getCurrentAnalyzer() {
+    return analysisModel.currentAnalyzer();
+  }
+
+  private class ListenerFunctions {
+
+    void toggleMainPanel(ActionEvent e) {
+      AnalysisPanelProvider.this.toggleMainPanel(e.getActionCommand());
+    }
+
+    void showAnalysisChain(MouseEvent e) {
+      AnalysisPanelProvider.this.showAnalysisChainDialog();
+    }
+
+    void executeAnalysis(ActionEvent e) {
+      AnalysisPanelProvider.this.executeAnalysis();
+    }
+
+    void showAttributeValues(MouseEvent e) {
+      if (e.getClickCount() != 2 || e.isConsumed()) {
+        return;
+      }
+      int selectedIndex = tokensTable.rowAtPoint(e.getPoint());
+      if (selectedIndex < 0 || selectedIndex >= tokensTable.getRowCount()) {
+        return;
+      }
+      AnalysisPanelProvider.this.showAttributeValues(selectedIndex);
+    }
+
+  }
+
+  static final class TokensTableModel extends TableModelBase<TokensTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      TERM("Term", 0, String.class, 150),
+      ATTR("Attributes", 1, String.class, 1000);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    TokensTableModel() {
+      super();
+    }
+
+    TokensTableModel(List<Analysis.Token> tokens) {
+      super(tokens.size());
+      for (int i = 0; i < tokens.size(); i++) {
+        Analysis.Token token = tokens.get(i);
+        data[i][Column.TERM.getIndex()] = token.getTerm();
+        List<String> attValues = token.getAttributes().stream()
+            .flatMap(att -> att.getAttValues().entrySet().stream()
+                .map(e -> e.getKey() + "=" + e.getValue()))
+            .collect(Collectors.toList());
+        data[i][Column.ATTR.getIndex()] = String.join(",", attValues);
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisTabOperator.java
new file mode 100644
index 00000000000..555f1c0245c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/AnalysisTabOperator.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.models.analysis.CustomAnalyzerConfig;
+
+/** Operator for the Analysis tab */
+public interface AnalysisTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+
+  void setAnalyzerByType(String analyzerType);
+
+  void setAnalyzerByCustomConfiguration(CustomAnalyzerConfig config);
+
+  Analyzer getCurrentAnalyzer();
+
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/CommitsPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/CommitsPanelProvider.java
new file mode 100644
index 00000000000..d06abcc0789
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/CommitsPanelProvider.java
@@ -0,0 +1,575 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.DefaultComboBoxModel;
+import javax.swing.DefaultListModel;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JScrollPane;
+import javax.swing.JSplitPane;
+import javax.swing.JTable;
+import javax.swing.JTextArea;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.DirectoryObserver;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.commits.Commit;
+import org.apache.lucene.luke.models.commits.Commits;
+import org.apache.lucene.luke.models.commits.CommitsFactory;
+import org.apache.lucene.luke.models.commits.File;
+import org.apache.lucene.luke.models.commits.Segment;
+
+/** Provider of the Commits panel */
+public final class CommitsPanelProvider {
+
+  private final CommitsFactory commitsFactory = new CommitsFactory();
+
+  private final JComboBox<Long> commitGenCombo = new JComboBox<>();
+
+  private final JLabel deletedLbl = new JLabel();
+
+  private final JLabel segCntLbl = new JLabel();
+
+  private final JTextArea userDataTA = new JTextArea();
+
+  private final JTable filesTable = new JTable();
+
+  private final JTable segmentsTable = new JTable();
+
+  private final JRadioButton diagRB = new JRadioButton();
+
+  private final JRadioButton attrRB = new JRadioButton();
+
+  private final JRadioButton codecRB = new JRadioButton();
+
+  private final ButtonGroup rbGroup = new ButtonGroup();
+
+  private final JList<String> segDetailList = new JList<>();
+
+  private ListenerFunctions listeners = new ListenerFunctions();
+
+  private Commits commitsModel;
+
+  public CommitsPanelProvider() {
+    IndexHandler.getInstance().addObserver(new Observer());
+    DirectoryHandler.getInstance().addObserver(new Observer());
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createLineBorder(Color.gray));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, initUpperPanel(), initLowerPanel());
+    splitPane.setOpaque(false);
+    splitPane.setBorder(BorderFactory.createEmptyBorder());
+    splitPane.setDividerLocation(120);
+    panel.add(splitPane);
+
+    return panel;
+  }
+
+  private JPanel initUpperPanel() {
+    JPanel panel = new JPanel(new BorderLayout(20, 0));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JPanel left = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    left.setOpaque(false);
+    left.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.select_gen")));
+    commitGenCombo.addActionListener(listeners::selectGeneration);
+    left.add(commitGenCombo);
+    panel.add(left, BorderLayout.LINE_START);
+
+    JPanel right = new JPanel(new GridBagLayout());
+    right.setOpaque(false);
+    GridBagConstraints c1 = new GridBagConstraints();
+    c1.ipadx = 5;
+    c1.ipady = 5;
+
+    c1.gridx = 0;
+    c1.gridy = 0;
+    c1.weightx = 0.2;
+    c1.anchor = GridBagConstraints.EAST;
+    right.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.deleted")), c1);
+
+    c1.gridx = 1;
+    c1.gridy = 0;
+    c1.weightx = 0.5;
+    c1.anchor = GridBagConstraints.WEST;
+    right.add(deletedLbl, c1);
+
+    c1.gridx = 0;
+    c1.gridy = 1;
+    c1.weightx = 0.2;
+    c1.anchor = GridBagConstraints.EAST;
+    right.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.segcount")), c1);
+
+    c1.gridx = 1;
+    c1.gridy = 1;
+    c1.weightx = 0.5;
+    c1.anchor = GridBagConstraints.WEST;
+    right.add(segCntLbl, c1);
+
+    c1.gridx = 0;
+    c1.gridy = 2;
+    c1.weightx = 0.2;
+    c1.anchor = GridBagConstraints.EAST;
+    right.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.userdata")), c1);
+
+    userDataTA.setRows(3);
+    userDataTA.setColumns(30);
+    userDataTA.setLineWrap(true);
+    userDataTA.setWrapStyleWord(true);
+    userDataTA.setEditable(false);
+    JScrollPane userDataScroll = new JScrollPane(userDataTA, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+    c1.gridx = 1;
+    c1.gridy = 2;
+    c1.weightx = 0.5;
+    c1.anchor = GridBagConstraints.WEST;
+    right.add(userDataScroll, c1);
+
+    panel.add(right, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initLowerPanel() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, initFilesPanel(), initSegmentsPanel());
+    splitPane.setOpaque(false);
+    splitPane.setBorder(BorderFactory.createEmptyBorder());
+    splitPane.setDividerLocation(300);
+    panel.add(splitPane);
+    return panel;
+  }
+
+  private JPanel initFilesPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.files")));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(filesTable, ListSelectionModel.SINGLE_SELECTION, new FilesTableModel(), null, FilesTableModel.Column.FILENAME.getColumnWidth());
+    panel.add(new JScrollPane(filesTable), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initSegmentsPanel() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel segments = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    segments.setOpaque(false);
+    segments.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.segments")));
+    panel.add(segments);
+
+    TableUtils.setupTable(segmentsTable, ListSelectionModel.SINGLE_SELECTION, new SegmentsTableModel(),
+        new MouseAdapter() {
+          @Override
+          public void mouseClicked(MouseEvent e) {
+            listeners.showSegmentDetails(e);
+          }
+        },
+        SegmentsTableModel.Column.NAME.getColumnWidth(),
+        SegmentsTableModel.Column.MAXDOCS.getColumnWidth(),
+        SegmentsTableModel.Column.DELS.getColumnWidth(),
+        SegmentsTableModel.Column.DELGEN.getColumnWidth(),
+        SegmentsTableModel.Column.VERSION.getColumnWidth(),
+        SegmentsTableModel.Column.CODEC.getColumnWidth());
+    panel.add(new JScrollPane(segmentsTable));
+
+    JPanel segDetails = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    segDetails.setOpaque(false);
+    segDetails.add(new JLabel(MessageUtils.getLocalizedMessage("commits.label.segdetails")));
+    panel.add(segDetails);
+
+    JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    buttons.setOpaque(false);
+
+    diagRB.setText("Diagnostics");
+    diagRB.setActionCommand(ActionCommand.DIAGNOSTICS.name());
+    diagRB.setSelected(true);
+    diagRB.setEnabled(false);
+    diagRB.setOpaque(false);
+    diagRB.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        listeners.showSegmentDetails(e);
+      }
+    });
+    buttons.add(diagRB);
+
+    attrRB.setText("Attributes");
+    attrRB.setActionCommand(ActionCommand.ATTRIBUTES.name());
+    attrRB.setSelected(false);
+    attrRB.setEnabled(false);
+    attrRB.setOpaque(false);
+    attrRB.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        listeners.showSegmentDetails(e);
+      }
+    });
+    buttons.add(attrRB);
+
+    codecRB.setText("Codec");
+    codecRB.setActionCommand(ActionCommand.CODEC.name());
+    codecRB.setSelected(false);
+    codecRB.setEnabled(false);
+    codecRB.setOpaque(false);
+    codecRB.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        listeners.showSegmentDetails(e);
+      }
+    });
+    buttons.add(codecRB);
+
+    rbGroup.add(diagRB);
+    rbGroup.add(attrRB);
+    rbGroup.add(codecRB);
+
+    panel.add(buttons);
+
+    segDetailList.setVisibleRowCount(10);
+    panel.add(new JScrollPane(segDetailList));
+
+    return panel;
+  }
+
+  // control methods
+
+  private void selectGeneration() {
+    diagRB.setEnabled(false);
+    attrRB.setEnabled(false);
+    codecRB.setEnabled(false);
+    segDetailList.setModel(new DefaultListModel<>());
+
+    long commitGen = (long) commitGenCombo.getSelectedItem();
+    commitsModel.getCommit(commitGen).ifPresent(commit -> {
+      deletedLbl.setText(String.valueOf(commit.isDeleted()));
+      segCntLbl.setText(String.valueOf(commit.getSegCount()));
+      userDataTA.setText(commit.getUserData());
+    });
+
+    filesTable.setModel(new FilesTableModel(commitsModel.getFiles(commitGen)));
+    filesTable.setShowGrid(true);
+    filesTable.getColumnModel().getColumn(FilesTableModel.Column.FILENAME.getIndex()).setPreferredWidth(FilesTableModel.Column.FILENAME.getColumnWidth());
+
+    segmentsTable.setModel(new SegmentsTableModel(commitsModel.getSegments(commitGen)));
+    segmentsTable.setShowGrid(true);
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.NAME.getIndex()).setPreferredWidth(SegmentsTableModel.Column.NAME.getColumnWidth());
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.MAXDOCS.getIndex()).setPreferredWidth(SegmentsTableModel.Column.MAXDOCS.getColumnWidth());
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.DELS.getIndex()).setPreferredWidth(SegmentsTableModel.Column.DELS.getColumnWidth());
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.DELGEN.getIndex()).setPreferredWidth(SegmentsTableModel.Column.DELGEN.getColumnWidth());
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.VERSION.getIndex()).setPreferredWidth(SegmentsTableModel.Column.VERSION.getColumnWidth());
+    segmentsTable.getColumnModel().getColumn(SegmentsTableModel.Column.CODEC.getIndex()).setPreferredWidth(SegmentsTableModel.Column.CODEC.getColumnWidth());
+  }
+
+  private void showSegmentDetails() {
+    int selectedRow = segmentsTable.getSelectedRow();
+    if (commitGenCombo.getSelectedItem() == null ||
+        selectedRow < 0 || selectedRow >= segmentsTable.getRowCount()) {
+      return;
+    }
+
+    diagRB.setEnabled(true);
+    attrRB.setEnabled(true);
+    codecRB.setEnabled(true);
+
+    long commitGen = (long) commitGenCombo.getSelectedItem();
+    String segName = (String) segmentsTable.getValueAt(selectedRow, SegmentsTableModel.Column.NAME.getIndex());
+    ActionCommand command = ActionCommand.valueOf(rbGroup.getSelection().getActionCommand());
+
+    final DefaultListModel<String> detailsModel = new DefaultListModel<>();
+    switch (command) {
+      case DIAGNOSTICS:
+        commitsModel.getSegmentDiagnostics(commitGen, segName).entrySet().stream()
+            .map(entry -> entry.getKey() + " = " + entry.getValue())
+            .forEach(detailsModel::addElement);
+        break;
+      case ATTRIBUTES:
+        commitsModel.getSegmentAttributes(commitGen, segName).entrySet().stream()
+            .map(entry -> entry.getKey() + " = " + entry.getValue())
+            .forEach(detailsModel::addElement);
+        break;
+      case CODEC:
+        commitsModel.getSegmentCodec(commitGen, segName).ifPresent(codec -> {
+          Map<String, String> map = new HashMap<>();
+          map.put("Codec name", codec.getName());
+          map.put("Codec class name", codec.getClass().getName());
+          map.put("Compound format", codec.compoundFormat().getClass().getName());
+          map.put("DocValues format", codec.docValuesFormat().getClass().getName());
+          map.put("FieldInfos format", codec.fieldInfosFormat().getClass().getName());
+          map.put("LiveDocs format", codec.liveDocsFormat().getClass().getName());
+          map.put("Norms format", codec.normsFormat().getClass().getName());
+          map.put("Points format", codec.pointsFormat().getClass().getName());
+          map.put("Postings format", codec.postingsFormat().getClass().getName());
+          map.put("SegmentInfo format", codec.segmentInfoFormat().getClass().getName());
+          map.put("StoredFields format", codec.storedFieldsFormat().getClass().getName());
+          map.put("TermVectors format", codec.termVectorsFormat().getClass().getName());
+          map.entrySet().stream()
+              .map(entry -> entry.getKey() + " = " + entry.getValue()).forEach(detailsModel::addElement);
+        });
+        break;
+    }
+    segDetailList.setModel(detailsModel);
+
+  }
+
+  private class ListenerFunctions {
+
+    void selectGeneration(ActionEvent e) {
+      CommitsPanelProvider.this.selectGeneration();
+    }
+
+    void showSegmentDetails(MouseEvent e) {
+      CommitsPanelProvider.this.showSegmentDetails();
+    }
+
+  }
+
+  private class Observer implements IndexObserver, DirectoryObserver {
+
+    @Override
+    public void openDirectory(LukeState state) {
+      commitsModel = commitsFactory.newInstance(state.getDirectory(), state.getIndexPath());
+      populateCommitGenerations();
+    }
+
+    @Override
+    public void closeDirectory() {
+      close();
+    }
+
+    @Override
+    public void openIndex(LukeState state) {
+      if (state.hasDirectoryReader()) {
+        DirectoryReader dr = (DirectoryReader) state.getIndexReader();
+        commitsModel = commitsFactory.newInstance(dr, state.getIndexPath());
+        populateCommitGenerations();
+      }
+    }
+
+    @Override
+    public void closeIndex() {
+      close();
+    }
+
+    private void populateCommitGenerations() {
+      DefaultComboBoxModel<Long> segGenList = new DefaultComboBoxModel<>();
+      for (Commit commit : commitsModel.listCommits()) {
+        segGenList.addElement(commit.getGeneration());
+      }
+      commitGenCombo.setModel(segGenList);
+
+      if (segGenList.getSize() > 0) {
+        commitGenCombo.setSelectedIndex(0);
+      }
+    }
+
+    private void close() {
+      commitsModel = null;
+
+      commitGenCombo.setModel(new DefaultComboBoxModel<>());
+      deletedLbl.setText("");
+      segCntLbl.setText("");
+      userDataTA.setText("");
+      TableUtils.setupTable(filesTable, ListSelectionModel.SINGLE_SELECTION, new FilesTableModel(), null, FilesTableModel.Column.FILENAME.getColumnWidth());
+      TableUtils.setupTable(segmentsTable, ListSelectionModel.SINGLE_SELECTION, new SegmentsTableModel(), null,
+          SegmentsTableModel.Column.NAME.getColumnWidth(),
+          SegmentsTableModel.Column.MAXDOCS.getColumnWidth(),
+          SegmentsTableModel.Column.DELS.getColumnWidth(),
+          SegmentsTableModel.Column.DELGEN.getColumnWidth(),
+          SegmentsTableModel.Column.VERSION.getColumnWidth(),
+          SegmentsTableModel.Column.CODEC.getColumnWidth());
+      diagRB.setEnabled(false);
+      attrRB.setEnabled(false);
+      codecRB.setEnabled(false);
+      segDetailList.setModel(new DefaultListModel<>());
+    }
+  }
+
+  enum ActionCommand {
+    DIAGNOSTICS, ATTRIBUTES, CODEC;
+  }
+
+  static final class FilesTableModel extends TableModelBase<FilesTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      FILENAME("Filename", 0, String.class, 200),
+      SIZE("Size", 1, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    FilesTableModel() {
+      super();
+    }
+
+    FilesTableModel(List<File> files) {
+      super(files.size());
+      for (int i = 0; i < files.size(); i++) {
+        File file = files.get(i);
+        data[i][Column.FILENAME.getIndex()] = file.getFileName();
+        data[i][Column.SIZE.getIndex()] = file.getDisplaySize();
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class SegmentsTableModel extends TableModelBase<SegmentsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      NAME("Name", 0, String.class, 60),
+      MAXDOCS("Max docs", 1, Integer.class, 60),
+      DELS("Dels", 2, Integer.class, 60),
+      DELGEN("Del gen", 3, Long.class, 60),
+      VERSION("Lucene ver.", 4, String.class, 60),
+      CODEC("Codec", 5, String.class, 100),
+      SIZE("Size", 6, String.class, 150);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    SegmentsTableModel() {
+      super();
+    }
+
+    SegmentsTableModel(List<Segment> segments) {
+      super(segments.size());
+      for (int i = 0; i < segments.size(); i++) {
+        Segment segment = segments.get(i);
+        data[i][Column.NAME.getIndex()] = segment.getName();
+        data[i][Column.MAXDOCS.getIndex()] = segment.getMaxDoc();
+        data[i][Column.DELS.getIndex()] = segment.getDelCount();
+        data[i][Column.DELGEN.getIndex()] = segment.getDelGen();
+        data[i][Column.VERSION.getIndex()] = segment.getLuceneVer();
+        data[i][Column.CODEC.getIndex()] = segment.getCodecName();
+        data[i][Column.SIZE.getIndex()] = segment.getDisplaySize();
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/ComponentOperatorRegistry.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/ComponentOperatorRegistry.java
new file mode 100644
index 00000000000..0d9c99b0ec7
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/ComponentOperatorRegistry.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/** An utility class for interaction between components */
+public class ComponentOperatorRegistry {
+
+  private static final ComponentOperatorRegistry instance = new ComponentOperatorRegistry();
+
+  private final Map<Class<?>, Object> operators = new HashMap<>();
+
+  public static ComponentOperatorRegistry getInstance() {
+    return instance;
+  }
+
+  public <T extends ComponentOperator> void register(Class<T> type, T operator) {
+    if (!operators.containsKey(type)) {
+      operators.put(type, operator);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public <T extends ComponentOperator> Optional<T> get(Class<T> type) {
+    return Optional.ofNullable((T) operators.get(type));
+  }
+
+  /** marker interface for operators */
+  public interface ComponentOperator {
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsPanelProvider.java
new file mode 100644
index 00000000000..e9daece4db4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsPanelProvider.java
@@ -0,0 +1,1115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.JSpinner;
+import javax.swing.JSplitPane;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+import javax.swing.SpinnerModel;
+import javax.swing.SpinnerNumberModel;
+import javax.swing.event.ChangeEvent;
+import javax.swing.table.TableCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Toolkit;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.components.dialog.HelpDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.documents.AddDocumentDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.documents.DocValuesDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.documents.StoredValueDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.documents.TermVectorDialogFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.HelpHeaderRenderer;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.documents.DocValues;
+import org.apache.lucene.luke.models.documents.DocumentField;
+import org.apache.lucene.luke.models.documents.Documents;
+import org.apache.lucene.luke.models.documents.DocumentsFactory;
+import org.apache.lucene.luke.models.documents.TermPosting;
+import org.apache.lucene.luke.models.documents.TermVectorEntry;
+import org.apache.lucene.luke.util.BytesRefUtils;
+
+/** Provider of the Documents panel */
+public final class DocumentsPanelProvider implements DocumentsTabOperator {
+
+  private final DocumentsFactory documentsFactory = new DocumentsFactory();
+
+  private final MessageBroker messageBroker;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final AddDocumentDialogFactory addDocDialogFactory;
+
+  private final TermVectorDialogFactory tvDialogFactory;
+
+  private final DocValuesDialogFactory dvDialogFactory;
+
+  private final StoredValueDialogFactory valueDialogFactory;
+
+  private final TableCellRenderer tableHeaderRenderer;
+
+  private final JComboBox<String> fieldsCombo = new JComboBox<>();
+
+  private final JButton firstTermBtn = new JButton();
+
+  private final JTextField termTF = new JTextField();
+
+  private final JButton nextTermBtn = new JButton();
+
+  private final JTextField selectedTermTF = new JTextField();
+
+  private final JButton firstTermDocBtn = new JButton();
+
+  private final JTextField termDocIdxTF = new JTextField();
+
+  private final JButton nextTermDocBtn = new JButton();
+
+  private final JLabel termDocsNumLbl = new JLabel();
+
+  private final JTable posTable = new JTable();
+
+  private final JSpinner docNumSpnr = new JSpinner();
+
+  private final JLabel maxDocsLbl = new JLabel();
+
+  private final JButton mltBtn = new JButton();
+
+  private final JButton addDocBtn = new JButton();
+
+  private final JButton copyDocValuesBtn = new JButton();
+
+  private final JTable documentTable = new JTable();
+
+  private final JPopupMenu documentContextMenu = new JPopupMenu();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private Documents documentsModel;
+
+  public DocumentsPanelProvider() throws IOException {
+    this.messageBroker = MessageBroker.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+    this.addDocDialogFactory = AddDocumentDialogFactory.getInstance();
+    this.tvDialogFactory = TermVectorDialogFactory.getInstance();
+    this.dvDialogFactory = DocValuesDialogFactory.getInstance();
+    this.valueDialogFactory = StoredValueDialogFactory.getInstance();
+    HelpDialogFactory helpDialogFactory = HelpDialogFactory.getInstance();
+    this.tableHeaderRenderer = new HelpHeaderRenderer(
+        "About Flags", "Format: IdfpoNPSB#txxVDtxxxxTx/x",
+        createFlagsHelpDialog(), helpDialogFactory);
+
+    IndexHandler.getInstance().addObserver(new Observer());
+    operatorRegistry.register(DocumentsTabOperator.class, this);
+  }
+
+  private JComponent createFlagsHelpDialog() {
+    String[] values = new String[]{
+        "I - index options(docs, frequencies, positions, offsets)",
+        "N - norms",
+        "P - payloads",
+        "S - stored",
+        "B - binary stored values",
+        "#txx - numeric stored values(type, precision)",
+        "V - term vectors",
+        "Dtxxxxx - doc values(type)",
+        "Tx/x - point values(num bytes/dimension)"
+    };
+    JList<String> list = new JList<>(values);
+    return new JScrollPane(list);
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createLineBorder(Color.gray));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, initUpperPanel(), initLowerPanel());
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(0.4);
+    panel.add(splitPane);
+
+    setUpDocumentContextMenu();
+
+    return panel;
+  }
+
+  private JPanel initUpperPanel() {
+    JPanel panel = new JPanel(new GridBagLayout());
+    panel.setOpaque(false);
+    GridBagConstraints c = new GridBagConstraints();
+
+    c.gridx = 0;
+    c.gridy = 0;
+    c.weightx = 0.5;
+    c.anchor = GridBagConstraints.FIRST_LINE_START;
+    c.fill = GridBagConstraints.HORIZONTAL;
+    panel.add(initBrowseTermsPanel(), c);
+
+    c.gridx = 1;
+    c.gridy = 0;
+    c.weightx = 0.5;
+    c.anchor = GridBagConstraints.FIRST_LINE_START;
+    c.fill = GridBagConstraints.HORIZONTAL;
+    panel.add(initBrowseDocsByTermPanel(), c);
+
+    return panel;
+  }
+
+  private JPanel initBrowseTermsPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JPanel top = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    top.setOpaque(false);
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("documents.label.browse_terms"));
+    top.add(label);
+
+    panel.add(top, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new GridBagLayout());
+    center.setOpaque(false);
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.BOTH;
+
+    fieldsCombo.addActionListener(listeners::showFirstTerm);
+    c.gridx = 0;
+    c.gridy = 0;
+    c.insets = new Insets(5, 5, 5, 5);
+    c.weightx = 0.0;
+    c.gridwidth = 2;
+    center.add(fieldsCombo, c);
+
+    firstTermBtn.setText(FontUtils.elegantIconHtml("&#x38;", MessageUtils.getLocalizedMessage("documents.button.first_term")));
+    firstTermBtn.setMaximumSize(new Dimension(80, 30));
+    firstTermBtn.addActionListener(listeners::showFirstTerm);
+    c.gridx = 0;
+    c.gridy = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    c.weightx = 0.2;
+    c.gridwidth = 1;
+    center.add(firstTermBtn, c);
+
+    termTF.setColumns(20);
+    termTF.setMinimumSize(new Dimension(50, 25));
+    termTF.setFont(StyleConstants.FONT_MONOSPACE_LARGE);
+    termTF.addActionListener(listeners::seekNextTerm);
+    c.gridx = 1;
+    c.gridy = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    c.weightx = 0.5;
+    c.gridwidth = 1;
+    center.add(termTF, c);
+
+    nextTermBtn.setText(MessageUtils.getLocalizedMessage("documents.button.next"));
+    nextTermBtn.addActionListener(listeners::showNextTerm);
+    c.gridx = 2;
+    c.gridy = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    c.weightx = 0.1;
+    c.gridwidth = 1;
+    center.add(nextTermBtn, c);
+
+    panel.add(center, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.LEADING, 20, 5));
+    footer.setOpaque(false);
+    JLabel hintLbl = new JLabel(MessageUtils.getLocalizedMessage("documents.label.browse_terms_hint"));
+    footer.add(hintLbl);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private JPanel initBrowseDocsByTermPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JPanel center = new JPanel(new GridBagLayout());
+    center.setOpaque(false);
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.BOTH;
+
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("documents.label.browse_doc_by_term"));
+    c.gridx = 0;
+    c.gridy = 0;
+    c.weightx = 0.0;
+    c.gridwidth = 2;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(label, c);
+
+    selectedTermTF.setColumns(20);
+    selectedTermTF.setFont(StyleConstants.FONT_MONOSPACE_LARGE);
+    selectedTermTF.setEditable(false);
+    selectedTermTF.setBackground(Color.white);
+    c.gridx = 0;
+    c.gridy = 1;
+    c.weightx = 0.0;
+    c.gridwidth = 2;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(selectedTermTF, c);
+
+    firstTermDocBtn.setText(FontUtils.elegantIconHtml("&#x38;", MessageUtils.getLocalizedMessage("documents.button.first_termdoc")));
+    firstTermDocBtn.addActionListener(listeners::showFirstTermDoc);
+    c.gridx = 0;
+    c.gridy = 2;
+    c.weightx = 0.2;
+    c.gridwidth = 1;
+    c.insets = new Insets(5, 3, 5, 5);
+    center.add(firstTermDocBtn, c);
+
+    termDocIdxTF.setEditable(false);
+    termDocIdxTF.setBackground(Color.white);
+    c.gridx = 1;
+    c.gridy = 2;
+    c.weightx = 0.5;
+    c.gridwidth = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(termDocIdxTF, c);
+
+    nextTermDocBtn.setText(MessageUtils.getLocalizedMessage("documents.button.next"));
+    nextTermDocBtn.addActionListener(listeners::showNextTermDoc);
+    c.gridx = 2;
+    c.gridy = 2;
+    c.weightx = 0.2;
+    c.gridwidth = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(nextTermDocBtn, c);
+
+    termDocsNumLbl.setText("in ? docs");
+    c.gridx = 3;
+    c.gridy = 2;
+    c.weightx = 0.3;
+    c.gridwidth = 1;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(termDocsNumLbl, c);
+
+    TableUtils.setupTable(posTable, ListSelectionModel.SINGLE_SELECTION, new PosTableModel(), null,
+        PosTableModel.Column.POSITION.getColumnWidth(), PosTableModel.Column.OFFSETS.getColumnWidth(), PosTableModel.Column.PAYLOAD.getColumnWidth());
+    JScrollPane scrollPane = new JScrollPane(posTable);
+    scrollPane.setMinimumSize(new Dimension(100, 100));
+    c.gridx = 0;
+    c.gridy = 3;
+    c.gridwidth = 4;
+    c.insets = new Insets(5, 5, 5, 5);
+    center.add(scrollPane, c);
+
+    panel.add(center, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initLowerPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JPanel browseDocsPanel = new JPanel();
+    browseDocsPanel.setOpaque(false);
+    browseDocsPanel.setLayout(new BoxLayout(browseDocsPanel, BoxLayout.PAGE_AXIS));
+    browseDocsPanel.add(initBrowseDocsBar());
+
+    JPanel browseDocsNote1 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    browseDocsNote1.setOpaque(false);
+    browseDocsNote1.add(new JLabel(MessageUtils.getLocalizedMessage("documents.label.doc_table_note1")));
+    browseDocsPanel.add(browseDocsNote1);
+
+    JPanel browseDocsNote2 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    browseDocsNote2.setOpaque(false);
+    browseDocsNote2.add(new JLabel(MessageUtils.getLocalizedMessage("documents.label.doc_table_note2")));
+    browseDocsPanel.add(browseDocsNote2);
+
+    panel.add(browseDocsPanel, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(documentTable, ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, new DocumentsTableModel(), new MouseAdapter() {
+          @Override
+          public void mouseClicked(MouseEvent e) {
+            listeners.showDocumentContextMenu(e);
+          }
+        },
+        DocumentsTableModel.Column.FIELD.getColumnWidth(),
+        DocumentsTableModel.Column.FLAGS.getColumnWidth(),
+        DocumentsTableModel.Column.NORM.getColumnWidth(),
+        DocumentsTableModel.Column.VALUE.getColumnWidth());
+    JPanel flagsHeader = new JPanel(new FlowLayout(FlowLayout.CENTER));
+    flagsHeader.setOpaque(false);
+    flagsHeader.add(new JLabel("Flags"));
+    flagsHeader.add(new JLabel("Help"));
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.FLAGS.getIndex()).setHeaderValue(flagsHeader);
+
+    JScrollPane scrollPane = new JScrollPane(documentTable);
+    scrollPane.getHorizontalScrollBar().setAutoscrolls(false);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initBrowseDocsBar() {
+    JPanel panel = new JPanel(new GridLayout(1, 2));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(5, 0, 0, 5));
+
+    JPanel left = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    left.setOpaque(false);
+    JLabel label = new JLabel(FontUtils.elegantIconHtml("&#x68;", MessageUtils.getLocalizedMessage("documents.label.browse_doc_by_idx")));
+    label.setHorizontalTextPosition(JLabel.LEFT);
+    left.add(label);
+    docNumSpnr.setPreferredSize(new Dimension(100, 25));
+    docNumSpnr.addChangeListener(listeners::showCurrentDoc);
+    left.add(docNumSpnr);
+    maxDocsLbl.setText("in ? docs");
+    left.add(maxDocsLbl);
+    panel.add(left);
+
+    JPanel right = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    right.setOpaque(false);
+    copyDocValuesBtn.setText(FontUtils.elegantIconHtml("&#xe0e6;", MessageUtils.getLocalizedMessage("documents.buttont.copy_values")));
+    copyDocValuesBtn.setMargin(new Insets(5, 0, 5, 0));
+    copyDocValuesBtn.addActionListener(listeners::copySelectedOrAllStoredValues);
+    right.add(copyDocValuesBtn);
+    mltBtn.setText(FontUtils.elegantIconHtml("&#xe030;", MessageUtils.getLocalizedMessage("documents.button.mlt")));
+    mltBtn.setMargin(new Insets(5, 0, 5, 0));
+    mltBtn.addActionListener(listeners::mltSearch);
+    right.add(mltBtn);
+    addDocBtn.setText(FontUtils.elegantIconHtml("&#x59;", MessageUtils.getLocalizedMessage("documents.button.add")));
+    addDocBtn.setMargin(new Insets(5, 0, 5, 0));
+    addDocBtn.addActionListener(listeners::showAddDocumentDialog);
+    right.add(addDocBtn);
+    panel.add(right);
+
+    return panel;
+  }
+
+  private void setUpDocumentContextMenu() {
+    // show term vector
+    JMenuItem item1 = new JMenuItem(MessageUtils.getLocalizedMessage("documents.doctable.menu.item1"));
+    item1.addActionListener(listeners::showTermVectorDialog);
+    documentContextMenu.add(item1);
+
+    // show doc values
+    JMenuItem item2 = new JMenuItem(MessageUtils.getLocalizedMessage("documents.doctable.menu.item2"));
+    item2.addActionListener(listeners::showDocValuesDialog);
+    documentContextMenu.add(item2);
+
+    // show stored value
+    JMenuItem item3 = new JMenuItem(MessageUtils.getLocalizedMessage("documents.doctable.menu.item3"));
+    item3.addActionListener(listeners::showStoredValueDialog);
+    documentContextMenu.add(item3);
+
+    // copy stored value to clipboard
+    JMenuItem item4 = new JMenuItem(MessageUtils.getLocalizedMessage("documents.doctable.menu.item4"));
+    item4.addActionListener(listeners::copyStoredValue);
+    documentContextMenu.add(item4);
+  }
+
+  // control methods
+
+  private void showFirstTerm() {
+    String fieldName = (String) fieldsCombo.getSelectedItem();
+    if (fieldName == null || fieldName.length() == 0) {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.field.message.not_selected"));
+      return;
+    }
+
+    termDocIdxTF.setText("");
+    clearPosTable();
+
+    Optional<Term> firstTerm = documentsModel.firstTerm(fieldName);
+    String firstTermText = firstTerm.map(Term::text).orElse("");
+    termTF.setText(firstTermText);
+    selectedTermTF.setText(firstTermText);
+    if (firstTerm.isPresent()) {
+      String num = documentsModel.getDocFreq().map(String::valueOf).orElse("?");
+      termDocsNumLbl.setText("in " + num + " docs");
+
+      nextTermBtn.setEnabled(true);
+      termTF.setEditable(true);
+      firstTermDocBtn.setEnabled(true);
+    } else {
+      nextTermBtn.setEnabled(false);
+      termTF.setEditable(false);
+      firstTermDocBtn.setEnabled(false);
+    }
+    nextTermDocBtn.setEnabled(false);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void showNextTerm() {
+    termDocIdxTF.setText("");
+    clearPosTable();
+
+    Optional<Term> nextTerm = documentsModel.nextTerm();
+    String nextTermText = nextTerm.map(Term::text).orElse("");
+    termTF.setText(nextTermText);
+    selectedTermTF.setText(nextTermText);
+    if (nextTerm.isPresent()) {
+      String num = documentsModel.getDocFreq().map(String::valueOf).orElse("?");
+      termDocsNumLbl.setText("in " + num + " docs");
+
+      termTF.setEditable(true);
+      firstTermDocBtn.setEnabled(true);
+    } else {
+      nextTermBtn.setEnabled(false);
+      termTF.setEditable(false);
+      firstTermDocBtn.setEnabled(false);
+    }
+    nextTermDocBtn.setEnabled(false);
+    messageBroker.clearStatusMessage();
+  }
+
+  @Override
+  public void seekNextTerm() {
+    termDocIdxTF.setText("");
+    posTable.setModel(new PosTableModel());
+
+    String termText = termTF.getText();
+
+    Optional<Term> nextTerm = documentsModel.seekTerm(termText);
+    String nextTermText = nextTerm.map(Term::text).orElse("");
+    termTF.setText(nextTermText);
+    selectedTermTF.setText(nextTermText);
+    if (nextTerm.isPresent()) {
+      String num = documentsModel.getDocFreq().map(String::valueOf).orElse("?");
+      termDocsNumLbl.setText("in " + num + " docs");
+
+      termTF.setEditable(true);
+      firstTermDocBtn.setEnabled(true);
+    } else {
+      nextTermBtn.setEnabled(false);
+      termTF.setEditable(false);
+      firstTermDocBtn.setEnabled(false);
+    }
+    nextTermDocBtn.setEnabled(false);
+    messageBroker.clearStatusMessage();
+  }
+
+
+  private void clearPosTable() {
+    TableUtils.setupTable(posTable, ListSelectionModel.SINGLE_SELECTION, new PosTableModel(), null,
+        PosTableModel.Column.POSITION.getColumnWidth(),
+        PosTableModel.Column.OFFSETS.getColumnWidth(),
+        PosTableModel.Column.PAYLOAD.getColumnWidth());
+  }
+
+  @Override
+  public void showFirstTermDoc() {
+    int docid = documentsModel.firstTermDoc().orElse(-1);
+    if (docid < 0) {
+      nextTermDocBtn.setEnabled(false);
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.termdocs.message.not_available"));
+      return;
+    }
+    termDocIdxTF.setText(String.valueOf(1));
+    displayDoc(docid);
+
+    List<TermPosting> postings = documentsModel.getTermPositions();
+    posTable.setModel(new PosTableModel(postings));
+    posTable.getColumnModel().getColumn(PosTableModel.Column.POSITION.getIndex()).setPreferredWidth(PosTableModel.Column.POSITION.getColumnWidth());
+    posTable.getColumnModel().getColumn(PosTableModel.Column.OFFSETS.getIndex()).setPreferredWidth(PosTableModel.Column.OFFSETS.getColumnWidth());
+    posTable.getColumnModel().getColumn(PosTableModel.Column.PAYLOAD.getIndex()).setPreferredWidth(PosTableModel.Column.PAYLOAD.getColumnWidth());
+
+    nextTermDocBtn.setEnabled(true);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void showNextTermDoc() {
+    int docid = documentsModel.nextTermDoc().orElse(-1);
+    if (docid < 0) {
+      nextTermDocBtn.setEnabled(false);
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.termdocs.message.not_available"));
+      return;
+    }
+    int curIdx = Integer.parseInt(termDocIdxTF.getText());
+    termDocIdxTF.setText(String.valueOf(curIdx + 1));
+    displayDoc(docid);
+
+    List<TermPosting> postings = documentsModel.getTermPositions();
+    posTable.setModel(new PosTableModel(postings));
+
+    nextTermDocBtn.setDefaultCapable(true);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void showCurrentDoc() {
+    int docid = (Integer) docNumSpnr.getValue();
+    displayDoc(docid);
+  }
+
+  private void mltSearch() {
+    int docNum = (int) docNumSpnr.getValue();
+    operatorRegistry.get(SearchTabOperator.class).ifPresent(operator -> {
+      operator.mltSearch(docNum);
+      tabSwitcher.switchTab(TabbedPaneProvider.Tab.SEARCH);
+    });
+  }
+
+  private void showAddDocumentDialog() {
+    new DialogOpener<>(addDocDialogFactory).open("Add document", 600, 500,
+        (factory) -> {
+        });
+  }
+
+  private void showTermVectorDialog() {
+    int docid = (Integer) docNumSpnr.getValue();
+    String field = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.FIELD.getIndex());
+    List<TermVectorEntry> tvEntries = documentsModel.getTermVectors(docid, field);
+    if (tvEntries.isEmpty()) {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.termvector.message.not_available", field, docid));
+      return;
+    }
+
+    new DialogOpener<>(tvDialogFactory).open(
+        "Term Vector", 600, 400,
+        (factory) -> {
+          factory.setField(field);
+          factory.setTvEntries(tvEntries);
+        });
+    messageBroker.clearStatusMessage();
+  }
+
+  private void showDocValuesDialog() {
+    int docid = (Integer) docNumSpnr.getValue();
+    String field = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.FIELD.getIndex());
+    Optional<DocValues> docValues = documentsModel.getDocValues(docid, field);
+    if (docValues.isPresent()) {
+      new DialogOpener<>(dvDialogFactory).open(
+          "Doc Values", 400, 300,
+          (factory) -> {
+            factory.setValue(field, docValues.get());
+          });
+      messageBroker.clearStatusMessage();
+    } else {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.docvalues.message.not_available", field, docid));
+    }
+  }
+
+  private void showStoredValueDialog() {
+    int docid = (Integer) docNumSpnr.getValue();
+    String field = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.FIELD.getIndex());
+    String value = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.VALUE.getIndex());
+    if (Objects.isNull(value)) {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.stored.message.not_availabe", field, docid));
+      return;
+    }
+    new DialogOpener<>(valueDialogFactory).open(
+        "Stored Value", 400, 300,
+        (factory) -> {
+          factory.setField(field);
+          factory.setValue(value);
+        });
+    messageBroker.clearStatusMessage();
+  }
+
+  private void copyStoredValue() {
+    int docid = (Integer) docNumSpnr.getValue();
+    String field = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.FIELD.getIndex());
+    String value = (String) documentTable.getModel().getValueAt(documentTable.getSelectedRow(), DocumentsTableModel.Column.VALUE.getIndex());
+    if (Objects.isNull(value)) {
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("documents.stored.message.not_availabe", field, docid));
+      return;
+    }
+    Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+    StringSelection selection = new StringSelection(value);
+    clipboard.setContents(selection, null);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void copySelectedOrAllStoredValues() {
+    StringSelection selection;
+    if (documentTable.getSelectedRowCount() == 0) {
+      selection = copyAllValues();
+    } else {
+      selection = copySelectedValues();
+    }
+    Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+    clipboard.setContents(selection, null);
+    messageBroker.clearStatusMessage();
+  }
+
+  private StringSelection copyAllValues() {
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < documentTable.getRowCount(); i++) {
+      String value = (String) documentTable.getModel().getValueAt(i, DocumentsTableModel.Column.VALUE.getIndex());
+      if (Objects.nonNull(value)) {
+        sb.append((i == 0) ? value : System.lineSeparator() + value);
+      }
+    }
+    return new StringSelection(sb.toString());
+  }
+
+  private StringSelection copySelectedValues() {
+    StringBuilder sb = new StringBuilder();
+    boolean isFirst = true;
+    for (int rowIndex : documentTable.getSelectedRows()) {
+      String value = (String) documentTable.getModel().getValueAt(rowIndex, DocumentsTableModel.Column.VALUE.getIndex());
+      if (Objects.nonNull(value)) {
+        sb.append(isFirst ? value : System.lineSeparator() + value);
+        isFirst = false;
+      }
+    }
+    return new StringSelection(sb.toString());
+  }
+
+  @Override
+  public void browseTerm(String field, String term) {
+    fieldsCombo.setSelectedItem(field);
+    termTF.setText(term);
+    seekNextTerm();
+    showFirstTermDoc();
+  }
+
+  @Override
+  public void displayLatestDoc() {
+    int docid = documentsModel.getMaxDoc() - 1;
+    showDoc(docid);
+  }
+
+  @Override
+  public void displayDoc(int docid) {
+    showDoc(docid);
+  }
+
+  ;
+
+  private void showDoc(int docid) {
+    docNumSpnr.setValue(docid);
+
+    List<DocumentField> doc = documentsModel.getDocumentFields(docid);
+    documentTable.setModel(new DocumentsTableModel(doc));
+    documentTable.setFont(StyleConstants.FONT_MONOSPACE_LARGE);
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.FIELD.getIndex()).setPreferredWidth(DocumentsTableModel.Column.FIELD.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.FLAGS.getIndex()).setMinWidth(DocumentsTableModel.Column.FLAGS.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.FLAGS.getIndex()).setMaxWidth(DocumentsTableModel.Column.FIELD.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.NORM.getIndex()).setMinWidth(DocumentsTableModel.Column.NORM.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.NORM.getIndex()).setMaxWidth(DocumentsTableModel.Column.NORM.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.VALUE.getIndex()).setPreferredWidth(DocumentsTableModel.Column.VALUE.getColumnWidth());
+    documentTable.getColumnModel().getColumn(DocumentsTableModel.Column.FLAGS.getIndex()).setHeaderRenderer(tableHeaderRenderer);
+
+    messageBroker.clearStatusMessage();
+  }
+
+  private class ListenerFunctions {
+
+    void showFirstTerm(ActionEvent e) {
+      DocumentsPanelProvider.this.showFirstTerm();
+    }
+
+    void seekNextTerm(ActionEvent e) {
+      DocumentsPanelProvider.this.seekNextTerm();
+    }
+
+    void showNextTerm(ActionEvent e) {
+      DocumentsPanelProvider.this.showNextTerm();
+    }
+
+    void showFirstTermDoc(ActionEvent e) {
+      DocumentsPanelProvider.this.showFirstTermDoc();
+    }
+
+    void showNextTermDoc(ActionEvent e) {
+      DocumentsPanelProvider.this.showNextTermDoc();
+    }
+
+    void showCurrentDoc(ChangeEvent e) {
+      DocumentsPanelProvider.this.showCurrentDoc();
+    }
+
+    void mltSearch(ActionEvent e) {
+      DocumentsPanelProvider.this.mltSearch();
+    }
+
+    void showAddDocumentDialog(ActionEvent e) {
+      DocumentsPanelProvider.this.showAddDocumentDialog();
+    }
+
+    void showDocumentContextMenu(MouseEvent e) {
+      if (e.getClickCount() == 2 && !e.isConsumed()) {
+        int row = documentTable.rowAtPoint(e.getPoint());
+        if (row != documentTable.getSelectedRow()) {
+          documentTable.changeSelection(row, documentTable.getSelectedColumn(), false, false);
+        }
+        documentContextMenu.show(e.getComponent(), e.getX(), e.getY());
+      }
+    }
+
+    void showTermVectorDialog(ActionEvent e) {
+      DocumentsPanelProvider.this.showTermVectorDialog();
+    }
+
+    void showDocValuesDialog(ActionEvent e) {
+      DocumentsPanelProvider.this.showDocValuesDialog();
+    }
+
+    void showStoredValueDialog(ActionEvent e) {
+      DocumentsPanelProvider.this.showStoredValueDialog();
+    }
+
+    void copyStoredValue(ActionEvent e) {
+      DocumentsPanelProvider.this.copyStoredValue();
+    }
+
+    void copySelectedOrAllStoredValues(ActionEvent e) {
+      DocumentsPanelProvider.this.copySelectedOrAllStoredValues();
+    }
+
+  }
+
+  private class Observer implements IndexObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      documentsModel = documentsFactory.newInstance(state.getIndexReader());
+
+      addDocBtn.setEnabled(!state.readOnly() && state.hasDirectoryReader());
+
+      int maxDoc = documentsModel.getMaxDoc();
+      maxDocsLbl.setText("in " + maxDoc + " docs");
+      if (maxDoc > 0) {
+        int max = Math.max(maxDoc - 1, 0);
+        SpinnerModel spinnerModel = new SpinnerNumberModel(0, 0, max, 1);
+        docNumSpnr.setModel(spinnerModel);
+        docNumSpnr.setEnabled(true);
+        displayDoc(0);
+      } else {
+        docNumSpnr.setEnabled(false);
+      }
+
+      documentsModel.getFieldNames().stream().sorted().forEach(fieldsCombo::addItem);
+    }
+
+    @Override
+    public void closeIndex() {
+      maxDocsLbl.setText("in ? docs");
+      docNumSpnr.setEnabled(false);
+      fieldsCombo.removeAllItems();
+      termTF.setText("");
+      selectedTermTF.setText("");
+      termDocsNumLbl.setText("");
+      termDocIdxTF.setText("");
+
+      posTable.setModel(new PosTableModel());
+      documentTable.setModel(new DocumentsTableModel());
+    }
+  }
+
+  static final class PosTableModel extends TableModelBase<PosTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      POSITION("Position", 0, Integer.class, 80),
+      OFFSETS("Offsets", 1, String.class, 120),
+      PAYLOAD("Payload", 2, String.class, 300);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    PosTableModel() {
+      super();
+    }
+
+    PosTableModel(List<TermPosting> postings) {
+      super(postings.size());
+
+      for (int i = 0; i < postings.size(); i++) {
+        TermPosting p = postings.get(i);
+
+        int position = postings.get(i).getPosition();
+        String offset = null;
+        if (p.getStartOffset() >= 0 && p.getEndOffset() >= 0) {
+          offset = p.getStartOffset() + "-" + p.getEndOffset();
+        }
+        String payload = null;
+        if (p.getPayload() != null) {
+          payload = BytesRefUtils.decode(p.getPayload());
+        }
+
+        data[i] = new Object[]{position, offset, payload};
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class DocumentsTableModel extends TableModelBase<DocumentsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      FIELD("Field", 0, String.class, 150),
+      FLAGS("Flags", 1, String.class, 200),
+      NORM("Norm", 2, Long.class, 80),
+      VALUE("Value", 3, String.class, 500);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    DocumentsTableModel() {
+      super();
+    }
+
+    DocumentsTableModel(List<DocumentField> doc) {
+      super(doc.size());
+
+      for (int i = 0; i < doc.size(); i++) {
+        DocumentField docField = doc.get(i);
+        String field = docField.getName();
+        String flags = flags(docField);
+        long norm = docField.getNorm();
+        String value = null;
+        if (docField.getStringValue() != null) {
+          value = docField.getStringValue();
+        } else if (docField.getNumericValue() != null) {
+          value = String.valueOf(docField.getNumericValue());
+        } else if (docField.getBinaryValue() != null) {
+          value = String.valueOf(docField.getBinaryValue());
+        }
+        data[i] = new Object[]{field, flags, norm, value};
+      }
+    }
+
+    private static String flags(org.apache.lucene.luke.models.documents.DocumentField f) {
+      StringBuilder sb = new StringBuilder();
+      // index options
+      if (f.getIdxOptions() == null || f.getIdxOptions() == IndexOptions.NONE) {
+        sb.append("-----");
+      } else {
+        sb.append("I");
+        switch (f.getIdxOptions()) {
+          case DOCS:
+            sb.append("d---");
+            break;
+          case DOCS_AND_FREQS:
+            sb.append("df--");
+            break;
+          case DOCS_AND_FREQS_AND_POSITIONS:
+            sb.append("dfp-");
+            break;
+          case DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS:
+            sb.append("dfpo");
+            break;
+          default:
+            sb.append("----");
+        }
+      }
+      // has norm?
+      if (f.hasNorms()) {
+        sb.append("N");
+      } else {
+        sb.append("-");
+      }
+      // has payloads?
+      if (f.hasPayloads()) {
+        sb.append("P");
+      } else {
+        sb.append("-");
+      }
+      // stored?
+      if (f.isStored()) {
+        sb.append("S");
+      } else {
+        sb.append("-");
+      }
+      // binary?
+      if (f.getBinaryValue() != null) {
+        sb.append("B");
+      } else {
+        sb.append("-");
+      }
+      // numeric?
+      if (f.getNumericValue() == null) {
+        sb.append("----");
+      } else {
+        sb.append("#");
+        // try faking it
+        Number numeric = f.getNumericValue();
+        if (numeric instanceof Integer) {
+          sb.append("i32");
+        } else if (numeric instanceof Long) {
+          sb.append("i64");
+        } else if (numeric instanceof Float) {
+          sb.append("f32");
+        } else if (numeric instanceof Double) {
+          sb.append("f64");
+        } else if (numeric instanceof Short) {
+          sb.append("i16");
+        } else if (numeric instanceof Byte) {
+          sb.append("i08");
+        } else if (numeric instanceof BigDecimal) {
+          sb.append("b^d");
+        } else if (numeric instanceof BigInteger) {
+          sb.append("b^i");
+        } else {
+          sb.append("???");
+        }
+      }
+      // has term vector?
+      if (f.hasTermVectors()) {
+        sb.append("V");
+      } else {
+        sb.append("-");
+      }
+      // doc values
+      if (f.getDvType() == null || f.getDvType() == DocValuesType.NONE) {
+        sb.append("-------");
+      } else {
+        sb.append("D");
+        switch (f.getDvType()) {
+          case NUMERIC:
+            sb.append("number");
+            break;
+          case BINARY:
+            sb.append("binary");
+            break;
+          case SORTED:
+            sb.append("sorted");
+            break;
+          case SORTED_NUMERIC:
+            sb.append("srtnum");
+            break;
+          case SORTED_SET:
+            sb.append("srtset");
+            break;
+          default:
+            sb.append("??????");
+        }
+      }
+      // point values
+      if (f.getPointDimensionCount() == 0) {
+        sb.append("----");
+      } else {
+        sb.append("T");
+        sb.append(f.getPointNumBytes());
+        sb.append("/");
+        sb.append(f.getPointDimensionCount());
+      }
+      return sb.toString();
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsTabOperator.java
new file mode 100644
index 00000000000..a0618da1f0a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/DocumentsTabOperator.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+/** Operator for the Documents tab */
+public interface DocumentsTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void browseTerm(String field, String term);
+
+  void displayLatestDoc();
+
+  void displayDoc(int donid);
+
+  void seekNextTerm();
+
+  void showFirstTermDoc();
+}
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LogsPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LogsPanelProvider.java
new file mode 100644
index 00000000000..1d27cea9ff3
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LogsPanelProvider.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+import java.awt.BorderLayout;
+import java.awt.FlowLayout;
+
+import org.apache.lucene.luke.app.desktop.LukeMain;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Provider of the Logs panel */
+public final class LogsPanelProvider {
+
+  private final JTextArea logTextArea;
+
+  public LogsPanelProvider(JTextArea logTextArea) {
+    this.logTextArea = logTextArea;
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("logs.label.see_also")));
+
+    JLabel logPathLabel = new JLabel(LukeMain.LOG_FILE);
+    header.add(logPathLabel);
+
+    panel.add(header, BorderLayout.PAGE_START);
+
+    panel.add(new JScrollPane(logTextArea), BorderLayout.CENTER);
+    return panel;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowOperator.java
new file mode 100644
index 00000000000..ecc51c88140
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowOperator.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+
+/** Operator for the root window */
+public interface LukeWindowOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setColorTheme(Preferences.ColorTheme theme);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowProvider.java
new file mode 100644
index 00000000000..faf5c1c1e27
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/LukeWindowProvider.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JMenuBar;
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.JTextArea;
+import javax.swing.WindowConstants;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.io.IOException;
+
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.DirectoryObserver;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ImageUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TextAreaAppender;
+import org.apache.lucene.util.Version;
+
+/** Provider of the root window */
+public final class LukeWindowProvider implements LukeWindowOperator {
+
+  private static final String WINDOW_TITLE = MessageUtils.getLocalizedMessage("window.title") + " - v" + Version.LATEST.toString();
+
+  private final Preferences prefs;
+
+  private final MessageBroker messageBroker;
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final JMenuBar menuBar;
+
+  private final JTabbedPane tabbedPane;
+
+  private final JLabel messageLbl = new JLabel();
+
+  private final JLabel multiIcon = new JLabel();
+
+  private final JLabel readOnlyIcon = new JLabel();
+
+  private final JLabel noReaderIcon = new JLabel();
+
+  private JFrame frame = new JFrame();
+
+  public LukeWindowProvider() throws IOException {
+    // prepare log4j appender for Logs tab.
+    JTextArea logTextArea = new JTextArea();
+    logTextArea.setEditable(false);
+    TextAreaAppender.setTextArea(logTextArea);
+
+    this.prefs = PreferencesFactory.getInstance();
+    this.menuBar = new MenuBarProvider().get();
+    this.tabbedPane = new TabbedPaneProvider(logTextArea).get();
+    this.messageBroker = MessageBroker.getInstance();
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+
+    ComponentOperatorRegistry.getInstance().register(LukeWindowOperator.class, this);
+    Observer observer = new Observer();
+    DirectoryHandler.getInstance().addObserver(observer);
+    IndexHandler.getInstance().addObserver(observer);
+
+    messageBroker.registerReceiver(new MessageReceiverImpl());
+  }
+
+  public JFrame get() {
+    frame.setTitle(WINDOW_TITLE);
+    frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+
+    frame.setJMenuBar(menuBar);
+    frame.add(initMainPanel(), BorderLayout.CENTER);
+    frame.add(initMessagePanel(), BorderLayout.PAGE_END);
+
+    frame.setPreferredSize(new Dimension(950, 680));
+    frame.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+
+    return frame;
+  }
+
+  private JPanel initMainPanel() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+
+    tabbedPane.setEnabledAt(TabbedPaneProvider.Tab.OVERVIEW.index(), false);
+    tabbedPane.setEnabledAt(TabbedPaneProvider.Tab.DOCUMENTS.index(), false);
+    tabbedPane.setEnabledAt(TabbedPaneProvider.Tab.SEARCH.index(), false);
+    tabbedPane.setEnabledAt(TabbedPaneProvider.Tab.COMMITS.index(), false);
+
+    panel.add(tabbedPane);
+
+    panel.setOpaque(false);
+    return panel;
+  }
+
+  private JPanel initMessagePanel() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(0, 2, 2, 2));
+
+    JPanel innerPanel = new JPanel(new GridBagLayout());
+    innerPanel.setOpaque(false);
+    innerPanel.setBorder(BorderFactory.createLineBorder(Color.gray));
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.HORIZONTAL;
+
+    JPanel msgPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+    msgPanel.setOpaque(false);
+    msgPanel.add(messageLbl);
+
+    c.gridx = 0;
+    c.gridy = 0;
+    c.weightx = 0.8;
+    innerPanel.add(msgPanel, c);
+
+    JPanel iconPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT));
+    iconPanel.setOpaque(false);
+
+    multiIcon.setText(FontUtils.elegantIconHtml("&#xe08c;"));
+    multiIcon.setToolTipText(MessageUtils.getLocalizedMessage("tooltip.multi_reader"));
+    multiIcon.setVisible(false);
+    iconPanel.add(multiIcon);
+
+
+    readOnlyIcon.setText(FontUtils.elegantIconHtml("&#xe06c;"));
+    readOnlyIcon.setToolTipText(MessageUtils.getLocalizedMessage("tooltip.read_only"));
+    readOnlyIcon.setVisible(false);
+    iconPanel.add(readOnlyIcon);
+
+    noReaderIcon.setText(FontUtils.elegantIconHtml("&#xe077;"));
+    noReaderIcon.setToolTipText(MessageUtils.getLocalizedMessage("tooltip.no_reader"));
+    noReaderIcon.setVisible(false);
+    iconPanel.add(noReaderIcon);
+
+    JLabel luceneIcon = new JLabel(ImageUtils.createImageIcon("lucene.gif", "lucene", 16, 16));
+    iconPanel.add(luceneIcon);
+
+    c.gridx = 1;
+    c.gridy = 0;
+    c.weightx = 0.2;
+    innerPanel.add(iconPanel);
+    panel.add(innerPanel);
+
+    return panel;
+  }
+
+  @Override
+  public void setColorTheme(Preferences.ColorTheme theme) {
+    frame.getContentPane().setBackground(theme.getBackgroundColor());
+  }
+
+  private class Observer implements IndexObserver, DirectoryObserver {
+
+    @Override
+    public void openDirectory(LukeState state) {
+      multiIcon.setVisible(false);
+      readOnlyIcon.setVisible(false);
+      noReaderIcon.setVisible(true);
+
+      tabSwitcher.switchTab(TabbedPaneProvider.Tab.COMMITS);
+
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.directory_opened"));
+    }
+
+    @Override
+    public void closeDirectory() {
+      multiIcon.setVisible(false);
+      readOnlyIcon.setVisible(false);
+      noReaderIcon.setVisible(false);
+
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.directory_closed"));
+    }
+
+    @Override
+    public void openIndex(LukeState state) {
+      multiIcon.setVisible(!state.hasDirectoryReader());
+      readOnlyIcon.setVisible(state.readOnly());
+      noReaderIcon.setVisible(false);
+
+      if (state.readOnly()) {
+        messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.index_opened_ro"));
+      } else if (!state.hasDirectoryReader()) {
+        messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.index_opened_multi"));
+      } else {
+        messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.index_opened"));
+      }
+    }
+
+    @Override
+    public void closeIndex() {
+      multiIcon.setVisible(false);
+      readOnlyIcon.setVisible(false);
+      noReaderIcon.setVisible(false);
+
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("message.index_closed"));
+    }
+
+  }
+
+  private class MessageReceiverImpl implements MessageBroker.MessageReceiver {
+
+    @Override
+    public void showStatusMessage(String message) {
+      messageLbl.setText(message);
+    }
+
+    @Override
+    public void showUnknownErrorMessage() {
+      messageLbl.setText(MessageUtils.getLocalizedMessage("message.error.unknown"));
+    }
+
+    @Override
+    public void clearStatusMessage() {
+      messageLbl.setText("");
+    }
+
+    private MessageReceiverImpl() {
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/MenuBarProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/MenuBarProvider.java
new file mode 100644
index 00000000000..2a5008f4c2b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/MenuBarProvider.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import java.awt.event.ActionEvent;
+import java.io.IOException;
+
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.DirectoryObserver;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.AboutDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.CheckIndexDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.CreateIndexDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.OpenIndexDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.menubar.OptimizeIndexDialogFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.util.Version;
+
+/** Provider of the MenuBar */
+public final class MenuBarProvider {
+
+  private final Preferences prefs;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final DirectoryHandler directoryHandler;
+
+  private final IndexHandler indexHandler;
+
+  private final OpenIndexDialogFactory openIndexDialogFactory;
+
+  private final CreateIndexDialogFactory createIndexDialogFactory;
+
+  private final OptimizeIndexDialogFactory optimizeIndexDialogFactory;
+
+  private final CheckIndexDialogFactory checkIndexDialogFactory;
+
+  private final AboutDialogFactory aboutDialogFactory;
+
+  private final JMenuItem openIndexMItem = new JMenuItem();
+
+  private final JMenuItem reopenIndexMItem = new JMenuItem();
+
+  private final JMenuItem createIndexMItem = new JMenuItem();
+
+  private final JMenuItem closeIndexMItem = new JMenuItem();
+
+  private final JMenuItem grayThemeMItem = new JMenuItem();
+
+  private final JMenuItem classicThemeMItem = new JMenuItem();
+
+  private final JMenuItem sandstoneThemeMItem = new JMenuItem();
+
+  private final JMenuItem navyThemeMItem = new JMenuItem();
+
+  private final JMenuItem exitMItem = new JMenuItem();
+
+  private final JMenuItem optimizeIndexMItem = new JMenuItem();
+
+  private final JMenuItem checkIndexMItem = new JMenuItem();
+
+  private final JMenuItem aboutMItem = new JMenuItem();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  public MenuBarProvider() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.directoryHandler = DirectoryHandler.getInstance();
+    this.indexHandler = IndexHandler.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.openIndexDialogFactory = OpenIndexDialogFactory.getInstance();
+    this.createIndexDialogFactory = CreateIndexDialogFactory.getInstance();
+    this.optimizeIndexDialogFactory = OptimizeIndexDialogFactory.getInstance();
+    this.checkIndexDialogFactory = CheckIndexDialogFactory.getInstance();
+    this.aboutDialogFactory = AboutDialogFactory.getInstance();
+
+    Observer observer = new Observer();
+    directoryHandler.addObserver(observer);
+    indexHandler.addObserver(observer);
+  }
+
+  public JMenuBar get() {
+    JMenuBar menuBar = new JMenuBar();
+
+    menuBar.add(createFileMenu());
+    menuBar.add(createToolsMenu());
+    menuBar.add(createHelpMenu());
+
+    return menuBar;
+  }
+
+  private JMenu createFileMenu() {
+    JMenu fileMenu = new JMenu(MessageUtils.getLocalizedMessage("menu.file"));
+
+    openIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.open_index"));
+    openIndexMItem.addActionListener(listeners::showOpenIndexDialog);
+    fileMenu.add(openIndexMItem);
+
+    reopenIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.reopen_index"));
+    reopenIndexMItem.setEnabled(false);
+    reopenIndexMItem.addActionListener(listeners::reopenIndex);
+    fileMenu.add(reopenIndexMItem);
+
+    createIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.create_index"));
+    createIndexMItem.addActionListener(listeners::showCreateIndexDialog);
+    fileMenu.add(createIndexMItem);
+
+
+    closeIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.close_index"));
+    closeIndexMItem.setEnabled(false);
+    closeIndexMItem.addActionListener(listeners::closeIndex);
+    fileMenu.add(closeIndexMItem);
+
+    fileMenu.addSeparator();
+
+    JMenu settingsMenu = new JMenu(MessageUtils.getLocalizedMessage("menu.settings"));
+    JMenu themeMenu = new JMenu(MessageUtils.getLocalizedMessage("menu.color"));
+    grayThemeMItem.setText(MessageUtils.getLocalizedMessage("menu.item.theme_gray"));
+    grayThemeMItem.addActionListener(listeners::changeThemeToGray);
+    themeMenu.add(grayThemeMItem);
+    classicThemeMItem.setText(MessageUtils.getLocalizedMessage("menu.item.theme_classic"));
+    classicThemeMItem.addActionListener(listeners::changeThemeToClassic);
+    themeMenu.add(classicThemeMItem);
+    sandstoneThemeMItem.setText(MessageUtils.getLocalizedMessage("menu.item.theme_sandstone"));
+    sandstoneThemeMItem.addActionListener(listeners::changeThemeToSandstone);
+    themeMenu.add(sandstoneThemeMItem);
+    navyThemeMItem.setText(MessageUtils.getLocalizedMessage("menu.item.theme_navy"));
+    navyThemeMItem.addActionListener(listeners::changeThemeToNavy);
+    themeMenu.add(navyThemeMItem);
+    settingsMenu.add(themeMenu);
+    fileMenu.add(settingsMenu);
+
+    fileMenu.addSeparator();
+
+    exitMItem.setText(MessageUtils.getLocalizedMessage("menu.item.exit"));
+    exitMItem.addActionListener(listeners::exit);
+    fileMenu.add(exitMItem);
+
+    return fileMenu;
+  }
+
+  private JMenu createToolsMenu() {
+    JMenu toolsMenu = new JMenu(MessageUtils.getLocalizedMessage("menu.tools"));
+    optimizeIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.optimize"));
+    optimizeIndexMItem.setEnabled(false);
+    optimizeIndexMItem.addActionListener(listeners::showOptimizeIndexDialog);
+    toolsMenu.add(optimizeIndexMItem);
+    checkIndexMItem.setText(MessageUtils.getLocalizedMessage("menu.item.check_index"));
+    checkIndexMItem.setEnabled(false);
+    checkIndexMItem.addActionListener(listeners::showCheckIndexDialog);
+    toolsMenu.add(checkIndexMItem);
+    return toolsMenu;
+  }
+
+  private JMenu createHelpMenu() {
+    JMenu helpMenu = new JMenu(MessageUtils.getLocalizedMessage("menu.help"));
+    aboutMItem.setText(MessageUtils.getLocalizedMessage("menu.item.about"));
+    aboutMItem.addActionListener(listeners::showAboutDialog);
+    helpMenu.add(aboutMItem);
+    return helpMenu;
+  }
+
+  private class ListenerFunctions {
+
+    void showOpenIndexDialog(ActionEvent e) {
+      new DialogOpener<>(openIndexDialogFactory).open(MessageUtils.getLocalizedMessage("openindex.dialog.title"), 600, 420,
+          (factory) -> {});
+    }
+
+    void showCreateIndexDialog(ActionEvent e) {
+      new DialogOpener<>(createIndexDialogFactory).open(MessageUtils.getLocalizedMessage("createindex.dialog.title"), 600, 360,
+          (factory) -> {});
+    }
+
+    void reopenIndex(ActionEvent e) {
+      indexHandler.reOpen();
+    }
+
+    void closeIndex(ActionEvent e) {
+      close();
+    }
+
+    void changeThemeToGray(ActionEvent e) {
+      changeTheme(Preferences.ColorTheme.GRAY);
+    }
+
+    void changeThemeToClassic(ActionEvent e) {
+      changeTheme(Preferences.ColorTheme.CLASSIC);
+    }
+
+    void changeThemeToSandstone(ActionEvent e) {
+      changeTheme(Preferences.ColorTheme.SANDSTONE);
+    }
+
+    void changeThemeToNavy(ActionEvent e) {
+      changeTheme(Preferences.ColorTheme.NAVY);
+    }
+
+    private void changeTheme(Preferences.ColorTheme theme) {
+      try {
+        prefs.setColorTheme(theme);
+        operatorRegistry.get(LukeWindowOperator.class).ifPresent(operator -> operator.setColorTheme(theme));
+      } catch (IOException e) {
+        throw new LukeException("Failed to set color theme : " + theme.name(), e);
+      }
+    }
+
+    void exit(ActionEvent e) {
+      close();
+      System.exit(0);
+    }
+
+    private void close() {
+      directoryHandler.close();
+      indexHandler.close();
+    }
+
+    void showOptimizeIndexDialog(ActionEvent e) {
+      new DialogOpener<>(optimizeIndexDialogFactory).open("Optimize index", 600, 600,
+          factory -> {
+          });
+    }
+
+    void showCheckIndexDialog(ActionEvent e) {
+      new DialogOpener<>(checkIndexDialogFactory).open("Check index", 600, 600,
+          factory -> {
+          });
+    }
+
+    void showAboutDialog(ActionEvent e) {
+      final String title = "About Luke v" + Version.LATEST.toString();
+      new DialogOpener<>(aboutDialogFactory).open(title, 800, 480,
+          factory -> {
+          });
+    }
+
+  }
+
+  private class Observer implements IndexObserver, DirectoryObserver {
+
+    @Override
+    public void openDirectory(LukeState state) {
+      reopenIndexMItem.setEnabled(false);
+      closeIndexMItem.setEnabled(false);
+      optimizeIndexMItem.setEnabled(false);
+      checkIndexMItem.setEnabled(true);
+    }
+
+    @Override
+    public void closeDirectory() {
+      close();
+    }
+
+    @Override
+    public void openIndex(LukeState state) {
+      reopenIndexMItem.setEnabled(true);
+      closeIndexMItem.setEnabled(true);
+      if (!state.readOnly() && state.hasDirectoryReader()) {
+        optimizeIndexMItem.setEnabled(true);
+      }
+      if (state.hasDirectoryReader()) {
+        checkIndexMItem.setEnabled(true);
+      }
+    }
+
+    @Override
+    public void closeIndex() {
+      close();
+    }
+
+    private void close() {
+      reopenIndexMItem.setEnabled(false);
+      closeIndexMItem.setEnabled(false);
+      optimizeIndexMItem.setEnabled(false);
+      checkIndexMItem.setEnabled(false);
+    }
+
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/OverviewPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/OverviewPanelProvider.java
new file mode 100644
index 00000000000..c85e93bcd7c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/OverviewPanelProvider.java
@@ -0,0 +1,644 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.JSpinner;
+import javax.swing.JSplitPane;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+import javax.swing.SpinnerNumberModel;
+import javax.swing.table.DefaultTableCellRenderer;
+import javax.swing.table.TableRowSorter;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.overview.Overview;
+import org.apache.lucene.luke.models.overview.OverviewFactory;
+import org.apache.lucene.luke.models.overview.TermCountsOrder;
+import org.apache.lucene.luke.models.overview.TermStats;
+
+/** Provider of the Overview panel */
+public final class OverviewPanelProvider {
+
+  private static final int GRIDX_DESC = 0;
+  private static final int GRIDX_VAL = 1;
+  private static final double WEIGHTX_DESC = 0.1;
+  private static final double WEIGHTX_VAL = 0.9;
+
+  private final OverviewFactory overviewFactory = new OverviewFactory();
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final MessageBroker messageBroker;
+
+  private final JPanel panel = new JPanel();
+
+  private final JLabel indexPathLbl = new JLabel();
+
+  private final JLabel numFieldsLbl = new JLabel();
+
+  private final JLabel numDocsLbl = new JLabel();
+
+  private final JLabel numTermsLbl = new JLabel();
+
+  private final JLabel delOptLbl = new JLabel();
+
+  private final JLabel indexVerLbl = new JLabel();
+
+  private final JLabel indexFmtLbl = new JLabel();
+
+  private final JLabel dirImplLbl = new JLabel();
+
+  private final JLabel commitPointLbl = new JLabel();
+
+  private final JLabel commitUserDataLbl = new JLabel();
+
+  private final JTable termCountsTable = new JTable();
+
+  private final JTextField selectedField = new JTextField();
+
+  private final JButton showTopTermsBtn = new JButton();
+
+  private final JSpinner numTopTermsSpnr = new JSpinner();
+
+  private final JTable topTermsTable = new JTable();
+
+  private final JPopupMenu topTermsContextMenu = new JPopupMenu();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private Overview overviewModel;
+
+  public OverviewPanelProvider() {
+    this.messageBroker = MessageBroker.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+
+    IndexHandler.getInstance().addObserver(new Observer());
+  }
+
+  public JPanel get() {
+    panel.setOpaque(false);
+    panel.setLayout(new GridLayout(1, 1));
+    panel.setBorder(BorderFactory.createLineBorder(Color.gray));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, initUpperPanel(), initLowerPanel());
+    splitPane.setDividerLocation(0.4);
+    splitPane.setOpaque(false);
+    panel.add(splitPane);
+
+    setUpTopTermsContextMenu();
+
+    return panel;
+  }
+
+  private JPanel initUpperPanel() {
+    JPanel panel = new JPanel(new GridBagLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.insets = new Insets(2, 10, 2, 2);
+    c.gridy = 0;
+
+    c.gridx = GRIDX_DESC;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.index_path"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    indexPathLbl.setText("?");
+    panel.add(indexPathLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.num_fields"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    numFieldsLbl.setText("?");
+    panel.add(numFieldsLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.num_docs"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    numDocsLbl.setText("?");
+    panel.add(numDocsLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.num_terms"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    numTermsLbl.setText("?");
+    panel.add(numTermsLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.del_opt"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    delOptLbl.setText("?");
+    panel.add(delOptLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.index_version"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    indexVerLbl.setText("?");
+    panel.add(indexVerLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.index_format"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    indexFmtLbl.setText("?");
+    panel.add(indexFmtLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.dir_impl"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    dirImplLbl.setText("?");
+    panel.add(dirImplLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.commit_point"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    commitPointLbl.setText("?");
+    panel.add(commitPointLbl, c);
+
+    c.gridx = GRIDX_DESC;
+    c.gridy += 1;
+    c.weightx = WEIGHTX_DESC;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.commit_userdata"), JLabel.RIGHT), c);
+
+    c.gridx = GRIDX_VAL;
+    c.weightx = WEIGHTX_VAL;
+    commitUserDataLbl.setText("?");
+    panel.add(commitUserDataLbl, c);
+
+    return panel;
+  }
+
+  private JPanel initLowerPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("overview.label.select_fields"));
+    label.setBorder(BorderFactory.createEmptyBorder(5, 10, 5, 10));
+    panel.add(label, BorderLayout.PAGE_START);
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, initTermCountsPanel(), initTopTermsPanel());
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(320);
+    splitPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+    panel.add(splitPane, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initTermCountsPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("overview.label.available_fields"));
+    label.setBorder(BorderFactory.createEmptyBorder(0, 0, 5, 0));
+    panel.add(label, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(termCountsTable, ListSelectionModel.SINGLE_SELECTION, new TermCountsTableModel(),
+        new MouseAdapter() {
+          @Override
+          public void mouseClicked(MouseEvent e) {
+            listeners.selectField(e);
+          }
+        }, TermCountsTableModel.Column.NAME.getColumnWidth(), TermCountsTableModel.Column.TERM_COUNT.getColumnWidth());
+    JScrollPane scrollPane = new JScrollPane(termCountsTable);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    panel.setOpaque(false);
+    return panel;
+  }
+
+  private JPanel initTopTermsPanel() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+
+    JPanel selectedPanel = new JPanel(new BorderLayout());
+    selectedPanel.setOpaque(false);
+    JPanel innerPanel = new JPanel();
+    innerPanel.setOpaque(false);
+    innerPanel.setLayout(new BoxLayout(innerPanel, BoxLayout.PAGE_AXIS));
+    innerPanel.setBorder(BorderFactory.createEmptyBorder(20, 0, 0, 0));
+    selectedPanel.add(innerPanel, BorderLayout.PAGE_START);
+
+    JPanel innerPanel1 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    innerPanel1.setOpaque(false);
+    innerPanel1.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.selected_field")));
+    innerPanel.add(innerPanel1);
+
+    selectedField.setColumns(20);
+    selectedField.setPreferredSize(new Dimension(100, 30));
+    selectedField.setFont(StyleConstants.FONT_MONOSPACE_LARGE);
+    selectedField.setEditable(false);
+    selectedField.setBackground(Color.white);
+    JPanel innerPanel2 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    innerPanel2.setOpaque(false);
+    innerPanel2.add(selectedField);
+    innerPanel.add(innerPanel2);
+
+    showTopTermsBtn.setText(MessageUtils.getLocalizedMessage("overview.button.show_terms"));
+    showTopTermsBtn.setPreferredSize(new Dimension(170, 40));
+    showTopTermsBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    showTopTermsBtn.addActionListener(listeners::showTopTerms);
+    showTopTermsBtn.setEnabled(false);
+    JPanel innerPanel3 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    innerPanel3.setOpaque(false);
+    innerPanel3.add(showTopTermsBtn);
+    innerPanel.add(innerPanel3);
+
+    JPanel innerPanel4 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    innerPanel4.setOpaque(false);
+    innerPanel4.add(new JLabel(MessageUtils.getLocalizedMessage("overview.label.num_top_terms")));
+    innerPanel.add(innerPanel4);
+
+    SpinnerNumberModel numberModel = new SpinnerNumberModel(50, 0, 1000, 1);
+    numTopTermsSpnr.setPreferredSize(new Dimension(80, 30));
+    numTopTermsSpnr.setModel(numberModel);
+    JPanel innerPanel5 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    innerPanel5.setOpaque(false);
+    innerPanel5.add(numTopTermsSpnr);
+    innerPanel.add(innerPanel5);
+
+    JPanel termsPanel = new JPanel(new BorderLayout());
+    termsPanel.setOpaque(false);
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("overview.label.top_terms"));
+    label.setBorder(BorderFactory.createEmptyBorder(0, 0, 5, 0));
+    termsPanel.add(label, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(topTermsTable, ListSelectionModel.SINGLE_SELECTION, new TopTermsTableModel(),
+        new MouseAdapter() {
+          @Override
+          public void mouseClicked(MouseEvent e) {
+            listeners.showTopTermsContextMenu(e);
+          }
+        }, TopTermsTableModel.Column.RANK.getColumnWidth(), TopTermsTableModel.Column.FREQ.getColumnWidth());
+    JScrollPane scrollPane = new JScrollPane(topTermsTable);
+    termsPanel.add(scrollPane, BorderLayout.CENTER);
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, selectedPanel, termsPanel);
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(180);
+    splitPane.setBorder(BorderFactory.createEmptyBorder());
+    panel.add(splitPane);
+
+    return panel;
+  }
+
+  private void setUpTopTermsContextMenu() {
+    JMenuItem item1 = new JMenuItem(MessageUtils.getLocalizedMessage("overview.toptermtable.menu.item1"));
+    item1.addActionListener(listeners::browseByTerm);
+    topTermsContextMenu.add(item1);
+
+    JMenuItem item2 = new JMenuItem(MessageUtils.getLocalizedMessage("overview.toptermtable.menu.item2"));
+    item2.addActionListener(listeners::searchByTerm);
+    topTermsContextMenu.add(item2);
+  }
+
+  // control methods
+
+  private void selectField() {
+    String field = getSelectedField();
+    selectedField.setText(field);
+    showTopTermsBtn.setEnabled(true);
+  }
+
+  private void showTopTerms() {
+    String field = getSelectedField();
+    int numTerms = (int) numTopTermsSpnr.getModel().getValue();
+    List<TermStats> termStats = overviewModel.getTopTerms(field, numTerms);
+
+    // update top terms table
+    topTermsTable.setModel(new TopTermsTableModel(termStats, numTerms));
+    topTermsTable.getColumnModel().getColumn(TopTermsTableModel.Column.RANK.getIndex()).setMaxWidth(TopTermsTableModel.Column.RANK.getColumnWidth());
+    topTermsTable.getColumnModel().getColumn(TopTermsTableModel.Column.FREQ.getIndex()).setMaxWidth(TopTermsTableModel.Column.FREQ.getColumnWidth());
+    messageBroker.clearStatusMessage();
+  }
+
+  private void browseByTerm() {
+    String field = getSelectedField();
+    String term = getSelectedTerm();
+    operatorRegistry.get(DocumentsTabOperator.class).ifPresent(operator -> {
+      operator.browseTerm(field, term);
+      tabSwitcher.switchTab(TabbedPaneProvider.Tab.DOCUMENTS);
+    });
+  }
+
+  private void searchByTerm() {
+    String field = getSelectedField();
+    String term = getSelectedTerm();
+    operatorRegistry.get(SearchTabOperator.class).ifPresent(operator -> {
+      operator.searchByTerm(field, term);
+      tabSwitcher.switchTab(TabbedPaneProvider.Tab.SEARCH);
+    });
+  }
+
+  private String getSelectedField() {
+    int selected = termCountsTable.getSelectedRow();
+    // need to convert selected row index to underlying model index
+    // https://docs.oracle.com/javase/8/docs/api/javax/swing/table/TableRowSorter.html
+    int row = termCountsTable.convertRowIndexToModel(selected);
+    if (row < 0 || row >= termCountsTable.getRowCount()) {
+      throw new IllegalStateException("Field is not selected.");
+    }
+    return (String) termCountsTable.getModel().getValueAt(row, TermCountsTableModel.Column.NAME.getIndex());
+  }
+
+  private String getSelectedTerm() {
+    int rowTerm = topTermsTable.getSelectedRow();
+    if (rowTerm < 0 || rowTerm >= topTermsTable.getRowCount()) {
+      throw new IllegalStateException("Term is not selected.");
+    }
+    return (String) topTermsTable.getModel().getValueAt(rowTerm, TopTermsTableModel.Column.TEXT.getIndex());
+  }
+
+  private class ListenerFunctions {
+
+    void selectField(MouseEvent e) {
+      OverviewPanelProvider.this.selectField();
+    }
+
+    void showTopTerms(ActionEvent e) {
+      OverviewPanelProvider.this.showTopTerms();
+    }
+
+    void showTopTermsContextMenu(MouseEvent e) {
+      if (e.getClickCount() == 2 && !e.isConsumed()) {
+        int row = topTermsTable.rowAtPoint(e.getPoint());
+        if (row != topTermsTable.getSelectedRow()) {
+          topTermsTable.changeSelection(row, topTermsTable.getSelectedColumn(), false, false);
+        }
+        topTermsContextMenu.show(e.getComponent(), e.getX(), e.getY());
+      }
+    }
+
+    void browseByTerm(ActionEvent e) {
+      OverviewPanelProvider.this.browseByTerm();
+    }
+
+    void searchByTerm(ActionEvent e) {
+      OverviewPanelProvider.this.searchByTerm();
+    }
+
+  }
+
+  private class Observer implements IndexObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      overviewModel = overviewFactory.newInstance(state.getIndexReader(), state.getIndexPath());
+
+      indexPathLbl.setText(overviewModel.getIndexPath());
+      indexPathLbl.setToolTipText(overviewModel.getIndexPath());
+      numFieldsLbl.setText(Integer.toString(overviewModel.getNumFields()));
+      numDocsLbl.setText(Integer.toString(overviewModel.getNumDocuments()));
+      numTermsLbl.setText(Long.toString(overviewModel.getNumTerms()));
+      String del = overviewModel.hasDeletions() ? String.format(Locale.ENGLISH, "Yes (%d)", overviewModel.getNumDeletedDocs()) : "No";
+      String opt = overviewModel.isOptimized().map(b -> b ? "Yes" : "No").orElse("?");
+      delOptLbl.setText(del + " / " + opt);
+      indexVerLbl.setText(overviewModel.getIndexVersion().map(v -> Long.toString(v)).orElse("?"));
+      indexFmtLbl.setText(overviewModel.getIndexFormat().orElse(""));
+      dirImplLbl.setText(overviewModel.getDirImpl().orElse(""));
+      commitPointLbl.setText(overviewModel.getCommitDescription().orElse("---"));
+      commitUserDataLbl.setText(overviewModel.getCommitUserData().orElse("---"));
+
+      // term counts table
+      Map<String, Long> termCounts = overviewModel.getSortedTermCounts(TermCountsOrder.COUNT_DESC);
+      long numTerms = overviewModel.getNumTerms();
+      termCountsTable.setModel(new TermCountsTableModel(numTerms, termCounts));
+      termCountsTable.setRowSorter(new TableRowSorter<>(termCountsTable.getModel()));
+      termCountsTable.getColumnModel().getColumn(TermCountsTableModel.Column.NAME.getIndex()).setMaxWidth(TermCountsTableModel.Column.NAME.getColumnWidth());
+      termCountsTable.getColumnModel().getColumn(TermCountsTableModel.Column.TERM_COUNT.getIndex()).setMaxWidth(TermCountsTableModel.Column.TERM_COUNT.getColumnWidth());
+      DefaultTableCellRenderer rightRenderer = new DefaultTableCellRenderer();
+      rightRenderer.setHorizontalAlignment(JLabel.RIGHT);
+      termCountsTable.getColumnModel().getColumn(TermCountsTableModel.Column.RATIO.getIndex()).setCellRenderer(rightRenderer);
+
+      // top terms table
+      topTermsTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+      topTermsTable.getColumnModel().getColumn(TopTermsTableModel.Column.RANK.getIndex()).setMaxWidth(TopTermsTableModel.Column.RANK.getColumnWidth());
+      topTermsTable.getColumnModel().getColumn(TopTermsTableModel.Column.FREQ.getIndex()).setMaxWidth(TopTermsTableModel.Column.FREQ.getColumnWidth());
+      topTermsTable.getColumnModel().setColumnMargin(StyleConstants.TABLE_COLUMN_MARGIN_DEFAULT);
+    }
+
+    @Override
+    public void closeIndex() {
+      indexPathLbl.setText("");
+      numFieldsLbl.setText("");
+      numDocsLbl.setText("");
+      numTermsLbl.setText("");
+      delOptLbl.setText("");
+      indexVerLbl.setText("");
+      indexFmtLbl.setText("");
+      dirImplLbl.setText("");
+      commitPointLbl.setText("");
+      commitUserDataLbl.setText("");
+
+      selectedField.setText("");
+      showTopTermsBtn.setEnabled(false);
+
+      termCountsTable.setRowSorter(null);
+      termCountsTable.setModel(new TermCountsTableModel());
+      topTermsTable.setModel(new TopTermsTableModel());
+    }
+
+  }
+
+  static final class TermCountsTableModel extends TableModelBase<TermCountsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      NAME("Name", 0, String.class, 150),
+      TERM_COUNT("Term count", 1, Long.class, 100),
+      RATIO("%", 2, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    TermCountsTableModel() {
+      super();
+    }
+
+    TermCountsTableModel(double numTerms, Map<String, Long> termCounts) {
+      super(termCounts.size());
+      int i = 0;
+      for (Map.Entry<String, Long> e : termCounts.entrySet()) {
+        String term = e.getKey();
+        Long count = e.getValue();
+        data[i++] = new Object[]{term, count, String.format(Locale.ENGLISH, "%.2f %%", count / numTerms * 100)};
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class TopTermsTableModel extends TableModelBase<TopTermsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      RANK("Rank", 0, Integer.class, 50),
+      FREQ("Freq", 1, Integer.class, 80),
+      TEXT("Text", 2, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    TopTermsTableModel() {
+      super();
+    }
+
+    TopTermsTableModel(List<TermStats> termStats, int numTerms) {
+      super(Math.min(numTerms, termStats.size()));
+      for (int i = 0; i < data.length; i++) {
+        int rank = i + 1;
+        int freq = termStats.get(i).getDocFreq();
+        String termText = termStats.get(i).getDecodedTermText();
+        data[i] = new Object[]{rank, freq, termText};
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java
new file mode 100644
index 00000000000..f94517a813e
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java
@@ -0,0 +1,834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JSplitPane;
+import javax.swing.JTabbedPane;
+import javax.swing.JTable;
+import javax.swing.JTextArea;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.components.dialog.ConfirmDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.search.ExplainDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.AnalyzerPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.FieldValuesPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.FieldValuesTabOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.MLTPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.MLTTabOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.QueryParserPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.QueryParserTabOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.SimilarityPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.SimilarityTabOperator;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.SortPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.SortTabOperator;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StringUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TabUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.search.MLTConfig;
+import org.apache.lucene.luke.models.search.QueryParserConfig;
+import org.apache.lucene.luke.models.search.Search;
+import org.apache.lucene.luke.models.search.SearchFactory;
+import org.apache.lucene.luke.models.search.SearchResults;
+import org.apache.lucene.luke.models.search.SimilarityConfig;
+import org.apache.lucene.luke.models.tools.IndexTools;
+import org.apache.lucene.luke.models.tools.IndexToolsFactory;
+import org.apache.lucene.search.Explanation;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.TotalHits;
+
+/** Provider of the Search panel */
+public final class SearchPanelProvider implements SearchTabOperator {
+
+  private static final int DEFAULT_PAGE_SIZE = 10;
+
+  private final SearchFactory searchFactory;
+
+  private final IndexToolsFactory toolsFactory;
+
+  private final IndexHandler indexHandler;
+
+  private final MessageBroker messageBroker;
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final ConfirmDialogFactory confirmDialogFactory;
+
+  private final ExplainDialogFactory explainDialogProvider;
+
+  private final JTabbedPane tabbedPane = new JTabbedPane();
+
+  private final JScrollPane qparser;
+
+  private final JScrollPane analyzer;
+
+  private final JScrollPane similarity;
+
+  private final JScrollPane sort;
+
+  private final JScrollPane values;
+
+  private final JScrollPane mlt;
+
+  private final JCheckBox termQueryCB = new JCheckBox();
+
+  private final JTextArea queryStringTA = new JTextArea();
+
+  private final JTextArea parsedQueryTA = new JTextArea();
+
+  private final JButton parseBtn = new JButton();
+
+  private final JCheckBox rewriteCB = new JCheckBox();
+
+  private final JButton searchBtn = new JButton();
+
+  private JCheckBox exactHitsCntCB = new JCheckBox();
+
+  private final JButton mltBtn = new JButton();
+
+  private final JFormattedTextField mltDocFTF = new JFormattedTextField();
+
+  private final JLabel totalHitsLbl = new JLabel();
+
+  private final JLabel startLbl = new JLabel();
+
+  private final JLabel endLbl = new JLabel();
+
+  private final JButton prevBtn = new JButton();
+
+  private final JButton nextBtn = new JButton();
+
+  private final JButton delBtn = new JButton();
+
+  private final JTable resultsTable = new JTable();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private Search searchModel;
+
+  private IndexTools toolsModel;
+
+  public SearchPanelProvider() throws IOException {
+    this.searchFactory = new SearchFactory();
+    this.toolsFactory = new IndexToolsFactory();
+    this.indexHandler = IndexHandler.getInstance();
+    this.messageBroker = MessageBroker.getInstance();
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.confirmDialogFactory = ConfirmDialogFactory.getInstance();
+    this.explainDialogProvider = ExplainDialogFactory.getInstance();
+    this.qparser = new QueryParserPaneProvider().get();
+    this.analyzer = new AnalyzerPaneProvider().get();
+    this.similarity = new SimilarityPaneProvider().get();
+    this.sort = new SortPaneProvider().get();
+    this.values = new FieldValuesPaneProvider().get();
+    this.mlt = new MLTPaneProvider().get();
+
+    indexHandler.addObserver(new Observer());
+    operatorRegistry.register(SearchTabOperator.class, this);
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createLineBorder(Color.gray));
+
+    JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, initUpperPanel(), initLowerPanel());
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(350);
+    panel.add(splitPane);
+
+    return panel;
+  }
+
+  private JSplitPane initUpperPanel() {
+    JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, initQuerySettingsPane(), initQueryPane());
+    splitPane.setOpaque(false);
+    splitPane.setDividerLocation(570);
+    return splitPane;
+  }
+
+  private JPanel initQuerySettingsPane() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JLabel label = new JLabel(MessageUtils.getLocalizedMessage("search.label.settings"));
+    panel.add(label, BorderLayout.PAGE_START);
+
+    tabbedPane.addTab("Query Parser", qparser);
+    tabbedPane.addTab("Analyzer", analyzer);
+    tabbedPane.addTab("Similarity", similarity);
+    tabbedPane.addTab("Sort", sort);
+    tabbedPane.addTab("Field Values", values);
+    tabbedPane.addTab("More Like This", mlt);
+
+    TabUtils.forceTransparent(tabbedPane);
+
+    panel.add(tabbedPane, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initQueryPane() {
+    JPanel panel = new JPanel(new GridBagLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.anchor = GridBagConstraints.LINE_START;
+
+    JLabel labelQE = new JLabel(MessageUtils.getLocalizedMessage("search.label.expression"));
+    c.gridx = 0;
+    c.gridy = 0;
+    c.gridwidth = 2;
+    c.weightx = 0.5;
+    c.insets = new Insets(2, 0, 2, 2);
+    panel.add(labelQE, c);
+
+    termQueryCB.setText(MessageUtils.getLocalizedMessage("search.checkbox.term"));
+    termQueryCB.addActionListener(listeners::toggleTermQuery);
+    termQueryCB.setOpaque(false);
+    c.gridx = 2;
+    c.gridy = 0;
+    c.gridwidth = 1;
+    c.weightx = 0.2;
+    c.insets = new Insets(2, 0, 2, 2);
+    panel.add(termQueryCB, c);
+
+    queryStringTA.setRows(4);
+    queryStringTA.setLineWrap(true);
+    queryStringTA.setText("*:*");
+    c.gridx = 0;
+    c.gridy = 1;
+    c.gridwidth = 3;
+    c.weightx = 0.0;
+    c.insets = new Insets(2, 0, 2, 2);
+    panel.add(new JScrollPane(queryStringTA, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER), c);
+
+    JLabel labelPQ = new JLabel(MessageUtils.getLocalizedMessage("search.label.parsed"));
+    c.gridx = 0;
+    c.gridy = 2;
+    c.gridwidth = 3;
+    c.weightx = 0.0;
+    c.insets = new Insets(8, 0, 2, 2);
+    panel.add(labelPQ, c);
+
+    parsedQueryTA.setRows(4);
+    parsedQueryTA.setLineWrap(true);
+    parsedQueryTA.setEditable(false);
+    c.gridx = 0;
+    c.gridy = 3;
+    c.gridwidth = 3;
+    c.weightx = 0.0;
+    c.insets = new Insets(2, 0, 2, 2);
+    panel.add(new JScrollPane(parsedQueryTA), c);
+
+    parseBtn.setText(FontUtils.elegantIconHtml("&#xe0df;", MessageUtils.getLocalizedMessage("search.button.parse")));
+    parseBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    parseBtn.setMargin(new Insets(3, 0, 3, 0));
+    parseBtn.addActionListener(listeners::execParse);
+    c.gridx = 0;
+    c.gridy = 4;
+    c.gridwidth = 1;
+    c.weightx = 0.2;
+    c.insets = new Insets(5, 0, 0, 2);
+    panel.add(parseBtn, c);
+
+    rewriteCB.setText(MessageUtils.getLocalizedMessage("search.checkbox.rewrite"));
+    rewriteCB.setOpaque(false);
+    c.gridx = 1;
+    c.gridy = 4;
+    c.gridwidth = 2;
+    c.weightx = 0.2;
+    c.insets = new Insets(5, 0, 0, 2);
+    panel.add(rewriteCB, c);
+
+    searchBtn.setText(FontUtils.elegantIconHtml("&#x55;", MessageUtils.getLocalizedMessage("search.button.search")));
+    searchBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    searchBtn.setMargin(new Insets(3, 0, 3, 0));
+    searchBtn.addActionListener(listeners::execSearch);
+    c.gridx = 0;
+    c.gridy = 5;
+    c.gridwidth = 1;
+    c.weightx = 0.2;
+    c.insets = new Insets(5, 0, 5, 0);
+    panel.add(searchBtn, c);
+
+    exactHitsCntCB.setText(MessageUtils.getLocalizedMessage("search.checkbox.exact_hits_cnt"));
+    exactHitsCntCB.setOpaque(false);
+    c.gridx = 1;
+    c.gridy = 5;
+    c.gridwidth = 2;
+    c.weightx = 0.2;
+    c.insets = new Insets(5, 0, 0, 2);
+    panel.add(exactHitsCntCB, c);
+
+    mltBtn.setText(FontUtils.elegantIconHtml("&#xe030;", MessageUtils.getLocalizedMessage("search.button.mlt")));
+    mltBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    mltBtn.setMargin(new Insets(3, 0, 3, 0));
+    mltBtn.addActionListener(listeners::execMLTSearch);
+    c.gridx = 0;
+    c.gridy = 6;
+    c.gridwidth = 1;
+    c.weightx = 0.3;
+    c.insets = new Insets(10, 0, 2, 0);
+    panel.add(mltBtn, c);
+
+    JPanel docNo = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    docNo.setOpaque(false);
+    JLabel docNoLabel = new JLabel("with doc #");
+    docNo.add(docNoLabel);
+    mltDocFTF.setColumns(8);
+    mltDocFTF.setValue(0);
+    docNo.add(mltDocFTF);
+    c.gridx = 1;
+    c.gridy = 6;
+    c.gridwidth = 2;
+    c.weightx = 0.3;
+    c.insets = new Insets(8, 0, 0, 2);
+    panel.add(docNo, c);
+
+    return panel;
+  }
+
+  private JPanel initLowerPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(initSearchResultsHeaderPane(), BorderLayout.PAGE_START);
+    panel.add(initSearchResultsTablePane(), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initSearchResultsHeaderPane() {
+    JPanel panel = new JPanel(new GridLayout(1, 2));
+    panel.setOpaque(false);
+
+    JLabel label = new JLabel(FontUtils.elegantIconHtml("&#xe025;", MessageUtils.getLocalizedMessage("search.label.results")));
+    label.setHorizontalTextPosition(JLabel.LEFT);
+    label.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 0));
+    panel.add(label);
+
+    JPanel resultsInfo = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    resultsInfo.setOpaque(false);
+    resultsInfo.setOpaque(false);
+
+    JLabel totalLabel = new JLabel(MessageUtils.getLocalizedMessage("search.label.total"));
+    resultsInfo.add(totalLabel);
+
+    totalHitsLbl.setText("?");
+    resultsInfo.add(totalHitsLbl);
+
+    prevBtn.setText(FontUtils.elegantIconHtml("&#x44;"));
+    prevBtn.setMargin(new Insets(5, 0, 5, 0));
+    prevBtn.setPreferredSize(new Dimension(30, 20));
+    prevBtn.setEnabled(false);
+    prevBtn.addActionListener(listeners::prevPage);
+    resultsInfo.add(prevBtn);
+
+    startLbl.setText("0");
+    resultsInfo.add(startLbl);
+
+    resultsInfo.add(new JLabel(" ~ "));
+
+    endLbl.setText("0");
+    resultsInfo.add(endLbl);
+
+    nextBtn.setText(FontUtils.elegantIconHtml("&#x45;"));
+    nextBtn.setMargin(new Insets(3, 0, 3, 0));
+    nextBtn.setPreferredSize(new Dimension(30, 20));
+    nextBtn.setEnabled(false);
+    nextBtn.addActionListener(listeners::nextPage);
+    resultsInfo.add(nextBtn);
+
+    JSeparator sep = new JSeparator(JSeparator.VERTICAL);
+    sep.setPreferredSize(new Dimension(5, 1));
+    resultsInfo.add(sep);
+
+    delBtn.setText(FontUtils.elegantIconHtml("&#xe07d;", MessageUtils.getLocalizedMessage("search.button.del_all")));
+    delBtn.setMargin(new Insets(5, 0, 5, 0));
+    delBtn.setEnabled(false);
+    delBtn.addActionListener(listeners::confirmDeletion);
+    resultsInfo.add(delBtn);
+
+    panel.add(resultsInfo, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel initSearchResultsTablePane() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel note = new JPanel(new FlowLayout(FlowLayout.LEADING, 5, 2));
+    note.setOpaque(false);
+    note.add(new JLabel(MessageUtils.getLocalizedMessage("search.label.results.note")));
+    panel.add(note, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(resultsTable, ListSelectionModel.SINGLE_SELECTION, new SearchResultsTableModel(),
+        new MouseAdapter() {
+          @Override
+          public void mousePressed(MouseEvent e) {
+            listeners.showContextMenuInResultsTable(e);
+          }
+        },
+        SearchResultsTableModel.Column.DOCID.getColumnWidth(),
+        SearchResultsTableModel.Column.SCORE.getColumnWidth());
+    JScrollPane scrollPane = new JScrollPane(resultsTable);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  // control methods
+
+  private void toggleTermQuery() {
+    if (termQueryCB.isSelected()) {
+      enableTermQuery();
+    } else {
+      disableTermQuery();
+    }
+  }
+
+  private void enableTermQuery() {
+    tabbedPane.setEnabledAt(Tab.QPARSER.index(), false);
+    tabbedPane.setEnabledAt(Tab.ANALYZER.index(), false);
+    tabbedPane.setEnabledAt(Tab.SIMILARITY.index(), false);
+    if (tabbedPane.getSelectedIndex() == Tab.QPARSER.index() ||
+        tabbedPane.getSelectedIndex() == Tab.ANALYZER.index() ||
+        tabbedPane.getSelectedIndex() == Tab.SIMILARITY.index() ||
+        tabbedPane.getSelectedIndex() == Tab.MLT.index()) {
+      tabbedPane.setSelectedIndex(Tab.SORT.index());
+    }
+    parseBtn.setEnabled(false);
+    rewriteCB.setEnabled(false);
+  }
+
+  private void disableTermQuery() {
+    tabbedPane.setEnabledAt(Tab.QPARSER.index(), true);
+    tabbedPane.setEnabledAt(Tab.ANALYZER.index(), true);
+    tabbedPane.setEnabledAt(Tab.SIMILARITY.index(), true);
+    parseBtn.setEnabled(true);
+    rewriteCB.setEnabled(true);
+  }
+
+  private void execParse() {
+    Query query = parse(rewriteCB.isSelected());
+    parsedQueryTA.setText(query.toString());
+    messageBroker.clearStatusMessage();
+  }
+
+  private void doSearch() {
+    Query query;
+    if (termQueryCB.isSelected()) {
+      // term query
+      if (StringUtils.isNullOrEmpty(queryStringTA.getText())) {
+        throw new LukeException("Query is not set.");
+      }
+      String[] tmp = queryStringTA.getText().split(":");
+      if (tmp.length < 2) {
+        throw new LukeException(String.format(Locale.ENGLISH, "Invalid query [ %s ]", queryStringTA.getText()));
+      }
+      query = new TermQuery(new Term(tmp[0].trim(), tmp[1].trim()));
+    } else {
+      query = parse(false);
+    }
+    SimilarityConfig simConfig = operatorRegistry.get(SimilarityTabOperator.class)
+        .map(SimilarityTabOperator::getConfig)
+        .orElse(new SimilarityConfig.Builder().build());
+    Sort sort = operatorRegistry.get(SortTabOperator.class)
+        .map(SortTabOperator::getSort)
+        .orElse(null);
+    Set<String> fieldsToLoad = operatorRegistry.get(FieldValuesTabOperator.class)
+        .map(FieldValuesTabOperator::getFieldsToLoad)
+        .orElse(Collections.emptySet());
+    SearchResults results = searchModel.search(query, simConfig, sort, fieldsToLoad, DEFAULT_PAGE_SIZE, exactHitsCntCB.isSelected());
+
+    TableUtils.setupTable(resultsTable, ListSelectionModel.SINGLE_SELECTION, new SearchResultsTableModel(), null,
+        SearchResultsTableModel.Column.DOCID.getColumnWidth(),
+        SearchResultsTableModel.Column.SCORE.getColumnWidth());
+    populateResults(results);
+
+    messageBroker.clearStatusMessage();
+  }
+
+  private void nextPage() {
+    searchModel.nextPage().ifPresent(this::populateResults);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void prevPage() {
+    searchModel.prevPage().ifPresent(this::populateResults);
+    messageBroker.clearStatusMessage();
+  }
+
+  private void doMLTSearch() {
+    if (Objects.isNull(mltDocFTF.getValue())) {
+      throw new LukeException("Doc num is not set.");
+    }
+    int docNum = (int) mltDocFTF.getValue();
+    MLTConfig mltConfig = operatorRegistry.get(MLTTabOperator.class)
+        .map(MLTTabOperator::getConfig)
+        .orElse(new MLTConfig.Builder().build());
+    Analyzer analyzer = operatorRegistry.get(AnalysisTabOperator.class)
+        .map(AnalysisTabOperator::getCurrentAnalyzer)
+        .orElse(new StandardAnalyzer());
+    Query query = searchModel.mltQuery(docNum, mltConfig, analyzer);
+    Set<String> fieldsToLoad = operatorRegistry.get(FieldValuesTabOperator.class)
+        .map(FieldValuesTabOperator::getFieldsToLoad)
+        .orElse(Collections.emptySet());
+    SearchResults results = searchModel.search(query, new SimilarityConfig.Builder().build(), fieldsToLoad, DEFAULT_PAGE_SIZE, false);
+
+    TableUtils.setupTable(resultsTable, ListSelectionModel.SINGLE_SELECTION, new SearchResultsTableModel(), null,
+        SearchResultsTableModel.Column.DOCID.getColumnWidth(),
+        SearchResultsTableModel.Column.SCORE.getColumnWidth());
+    populateResults(results);
+
+    messageBroker.clearStatusMessage();
+  }
+
+  private Query parse(boolean rewrite) {
+    String expr = StringUtils.isNullOrEmpty(queryStringTA.getText()) ? "*:*" : queryStringTA.getText();
+    String df = operatorRegistry.get(QueryParserTabOperator.class)
+        .map(QueryParserTabOperator::getDefaultField)
+        .orElse("");
+    QueryParserConfig config = operatorRegistry.get(QueryParserTabOperator.class)
+        .map(QueryParserTabOperator::getConfig)
+        .orElse(new QueryParserConfig.Builder().build());
+    Analyzer analyzer = operatorRegistry.get(AnalysisTabOperator.class)
+        .map(AnalysisTabOperator::getCurrentAnalyzer)
+        .orElse(new StandardAnalyzer());
+    return searchModel.parseQuery(expr, df, analyzer, config, rewrite);
+  }
+
+  private void populateResults(SearchResults res) {
+    totalHitsLbl.setText(String.valueOf(res.getTotalHits()));
+    if (res.getTotalHits().value > 0) {
+      startLbl.setText(String.valueOf(res.getOffset() + 1));
+      endLbl.setText(String.valueOf(res.getOffset() + res.size()));
+
+      prevBtn.setEnabled(res.getOffset() > 0);
+      nextBtn.setEnabled(res.getTotalHits().relation == TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO || res.getTotalHits().value > res.getOffset() + res.size());
+
+      if (!indexHandler.getState().readOnly() && indexHandler.getState().hasDirectoryReader()) {
+        delBtn.setEnabled(true);
+      }
+
+      resultsTable.setModel(new SearchResultsTableModel(res));
+      resultsTable.getColumnModel().getColumn(SearchResultsTableModel.Column.DOCID.getIndex()).setPreferredWidth(SearchResultsTableModel.Column.DOCID.getColumnWidth());
+      resultsTable.getColumnModel().getColumn(SearchResultsTableModel.Column.SCORE.getIndex()).setPreferredWidth(SearchResultsTableModel.Column.SCORE.getColumnWidth());
+      resultsTable.getColumnModel().getColumn(SearchResultsTableModel.Column.VALUE.getIndex()).setPreferredWidth(SearchResultsTableModel.Column.VALUE.getColumnWidth());
+    } else {
+      startLbl.setText("0");
+      endLbl.setText("0");
+      prevBtn.setEnabled(false);
+      nextBtn.setEnabled(false);
+      delBtn.setEnabled(false);
+    }
+  }
+
+  private void confirmDeletion() {
+    new DialogOpener<>(confirmDialogFactory).open("Confirm Deletion", 400, 200, (factory) -> {
+      factory.setMessage(MessageUtils.getLocalizedMessage("search.message.delete_confirm"));
+      factory.setCallback(this::deleteDocs);
+    });
+  }
+
+  private void deleteDocs() {
+    Query query = searchModel.getCurrentQuery();
+    if (query != null) {
+      toolsModel.deleteDocuments(query);
+      indexHandler.reOpen();
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("search.message.delete_success", query.toString()));
+    }
+    delBtn.setEnabled(false);
+  }
+
+  private JPopupMenu setupResultsContextMenuPopup() {
+    JPopupMenu popup = new JPopupMenu();
+
+    // show explanation
+    JMenuItem item1 = new JMenuItem(MessageUtils.getLocalizedMessage("search.results.menu.explain"));
+    item1.addActionListener(e -> {
+      int docid = (int) resultsTable.getModel().getValueAt(resultsTable.getSelectedRow(), SearchResultsTableModel.Column.DOCID.getIndex());
+      Explanation explanation = searchModel.explain(parse(false), docid);
+      new DialogOpener<>(explainDialogProvider).open("Explanation", 600, 400,
+          (factory) -> {
+            factory.setDocid(docid);
+            factory.setExplanation(explanation);
+          });
+    });
+    popup.add(item1);
+
+    // show all fields
+    JMenuItem item2 = new JMenuItem(MessageUtils.getLocalizedMessage("search.results.menu.showdoc"));
+    item2.addActionListener(e -> {
+      int docid = (int) resultsTable.getModel().getValueAt(resultsTable.getSelectedRow(), SearchResultsTableModel.Column.DOCID.getIndex());
+      operatorRegistry.get(DocumentsTabOperator.class).ifPresent(operator -> operator.displayDoc(docid));
+      tabSwitcher.switchTab(TabbedPaneProvider.Tab.DOCUMENTS);
+    });
+    popup.add(item2);
+
+    return popup;
+  }
+
+  @Override
+  public void searchByTerm(String field, String term) {
+    termQueryCB.setSelected(true);
+    enableTermQuery();
+    queryStringTA.setText(field + ":" + term);
+    doSearch();
+  }
+
+  @Override
+  public void mltSearch(int docNum) {
+    mltDocFTF.setValue(docNum);
+    doMLTSearch();
+    tabbedPane.setSelectedIndex(Tab.MLT.index());
+  }
+
+  @Override
+  public void enableExactHitsCB(boolean value) {
+    exactHitsCntCB.setEnabled(value);
+  }
+
+  @Override
+  public void setExactHits(boolean value) {
+    exactHitsCntCB.setSelected(value);
+  }
+
+  private class ListenerFunctions {
+
+    void toggleTermQuery(ActionEvent e) {
+      SearchPanelProvider.this.toggleTermQuery();
+    }
+
+    void execParse(ActionEvent e) {
+      SearchPanelProvider.this.execParse();
+    }
+
+    void execSearch(ActionEvent e) {
+      SearchPanelProvider.this.doSearch();
+    }
+
+    void nextPage(ActionEvent e) {
+      SearchPanelProvider.this.nextPage();
+    }
+
+    void prevPage(ActionEvent e) {
+      SearchPanelProvider.this.prevPage();
+    }
+
+    void execMLTSearch(ActionEvent e) {
+      SearchPanelProvider.this.doMLTSearch();
+    }
+
+    void confirmDeletion(ActionEvent e) {
+      SearchPanelProvider.this.confirmDeletion();
+    }
+
+    void showContextMenuInResultsTable(MouseEvent e) {
+      if (e.getClickCount() == 2 && !e.isConsumed()) {
+        SearchPanelProvider.this.setupResultsContextMenuPopup().show(e.getComponent(), e.getX(), e.getY());
+        setupResultsContextMenuPopup().show(e.getComponent(), e.getX(), e.getY());
+      }
+    }
+
+  }
+
+  private class Observer implements IndexObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      searchModel = searchFactory.newInstance(state.getIndexReader());
+      toolsModel = toolsFactory.newInstance(state.getIndexReader(), state.useCompound(), state.keepAllCommits());
+      operatorRegistry.get(QueryParserTabOperator.class).ifPresent(operator -> {
+        operator.setSearchableFields(searchModel.getSearchableFieldNames());
+        operator.setRangeSearchableFields(searchModel.getRangeSearchableFieldNames());
+      });
+      operatorRegistry.get(SortTabOperator.class).ifPresent(operator -> {
+        operator.setSearchModel(searchModel);
+        operator.setSortableFields(searchModel.getSortableFieldNames());
+      });
+      operatorRegistry.get(FieldValuesTabOperator.class).ifPresent(operator -> {
+        operator.setFields(searchModel.getFieldNames());
+      });
+      operatorRegistry.get(MLTTabOperator.class).ifPresent(operator -> {
+        operator.setFields(searchModel.getFieldNames());
+      });
+
+      queryStringTA.setText("*:*");
+      parsedQueryTA.setText("");
+      parseBtn.setEnabled(true);
+      searchBtn.setEnabled(true);
+      mltBtn.setEnabled(true);
+    }
+
+    @Override
+    public void closeIndex() {
+      searchModel = null;
+      toolsModel = null;
+
+      queryStringTA.setText("");
+      parsedQueryTA.setText("");
+      parseBtn.setEnabled(false);
+      searchBtn.setEnabled(false);
+      mltBtn.setEnabled(false);
+      totalHitsLbl.setText("0");
+      startLbl.setText("0");
+      endLbl.setText("0");
+      nextBtn.setEnabled(false);
+      prevBtn.setEnabled(false);
+      delBtn.setEnabled(false);
+      TableUtils.setupTable(resultsTable, ListSelectionModel.SINGLE_SELECTION, new SearchResultsTableModel(), null,
+          SearchResultsTableModel.Column.DOCID.getColumnWidth(),
+          SearchResultsTableModel.Column.SCORE.getColumnWidth());
+    }
+
+  }
+
+  /** tabs in the Search panel */
+  public enum Tab {
+    QPARSER(0), ANALYZER(1), SIMILARITY(2), SORT(3), VALUES(4), MLT(5);
+
+    private int tabIdx;
+
+    Tab(int tabIdx) {
+      this.tabIdx = tabIdx;
+    }
+
+    int index() {
+      return tabIdx;
+    }
+  }
+
+  static final class SearchResultsTableModel extends TableModelBase<SearchResultsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      DOCID("Doc ID", 0, Integer.class, 50),
+      SCORE("Score", 1, Float.class, 100),
+      VALUE("Field Values", 2, String.class, 800);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    SearchResultsTableModel() {
+      super();
+    }
+
+    SearchResultsTableModel(SearchResults results) {
+      super(results.size());
+      for (int i = 0; i < results.size(); i++) {
+        SearchResults.Doc doc = results.getHits().get(i);
+        data[i][Column.DOCID.getIndex()] = doc.getDocId();
+        if (!Float.isNaN(doc.getScore())) {
+          data[i][Column.SCORE.getIndex()] = doc.getScore();
+        } else {
+          data[i][Column.SCORE.getIndex()] = 1.0f;
+        }
+        List<String> concatValues = doc.getFieldValues().entrySet().stream().map(e -> {
+          String v = String.join(",", Arrays.asList(e.getValue()));
+          return e.getKey() + "=" + v + ";";
+        }).collect(Collectors.toList());
+        data[i][Column.VALUE.getIndex()] = String.join(" ", concatValues);
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchTabOperator.java
new file mode 100644
index 00000000000..05e70026914
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchTabOperator.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+/** Operator for the Search tab */
+public interface SearchTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void searchByTerm(String field, String term);
+
+  void mltSearch(int docNum);
+
+  void enableExactHitsCB(boolean value);
+
+  void setExactHits(boolean value);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabSwitcherProxy.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabSwitcherProxy.java
new file mode 100644
index 00000000000..42f2194c5ee
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabSwitcherProxy.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+/** An utility class for switching tabs. */
+public class TabSwitcherProxy {
+
+  private static final TabSwitcherProxy instance = new TabSwitcherProxy();
+
+  private TabSwitcher switcher;
+
+  public static TabSwitcherProxy getInstance() {
+    return instance;
+  }
+
+  public void set(TabSwitcher switcher) {
+    if (this.switcher == null) {
+      this.switcher = switcher;
+    }
+  }
+
+  public void switchTab(TabbedPaneProvider.Tab tab) {
+    if (switcher == null) {
+      throw new IllegalStateException();
+    }
+    switcher.switchTab(tab);
+  }
+
+  /** tab switcher */
+  public interface TabSwitcher {
+    void switchTab(TabbedPaneProvider.Tab tab);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabbedPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabbedPaneProvider.java
new file mode 100644
index 00000000000..c5fd73a0f68
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TabbedPaneProvider.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.JTextArea;
+import java.io.IOException;
+
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.DirectoryObserver;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.TabUtils;
+
+/** Provider of the Tabbed pane */
+public final class TabbedPaneProvider implements TabSwitcherProxy.TabSwitcher {
+
+  private final MessageBroker messageBroker;
+
+  private final JTabbedPane tabbedPane = new JTabbedPane();
+
+  private final JPanel overviewPanel;
+
+  private final JPanel documentsPanel;
+
+  private final JPanel searchPanel;
+
+  private final JPanel analysisPanel;
+
+  private final JPanel commitsPanel;
+
+  private final JPanel logsPanel;
+
+  public TabbedPaneProvider(JTextArea logTextArea) throws IOException {
+    this.overviewPanel = new OverviewPanelProvider().get();
+    this.documentsPanel = new DocumentsPanelProvider().get();
+    this.searchPanel = new SearchPanelProvider().get();
+    this.analysisPanel = new AnalysisPanelProvider().get();
+    this.commitsPanel = new CommitsPanelProvider().get();
+    this.logsPanel = new LogsPanelProvider(logTextArea).get();
+
+    this.messageBroker = MessageBroker.getInstance();
+
+    TabSwitcherProxy.getInstance().set(this);
+
+    Observer observer = new Observer();
+    IndexHandler.getInstance().addObserver(observer);
+    DirectoryHandler.getInstance().addObserver(observer);
+  }
+
+  public JTabbedPane get() {
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#xe009;", "Overview"), overviewPanel);
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#x69;", "Documents"), documentsPanel);
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#xe101;", "Search"), searchPanel);
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#xe104;", "Analysis"), analysisPanel);
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#xe0ea;", "Commits"), commitsPanel);
+    tabbedPane.addTab(FontUtils.elegantIconHtml("&#xe058;", "Logs"), logsPanel);
+
+    TabUtils.forceTransparent(tabbedPane);
+
+    return tabbedPane;
+  }
+
+  public void switchTab(Tab tab) {
+    tabbedPane.setSelectedIndex(tab.index());
+    tabbedPane.setVisible(false);
+    tabbedPane.setVisible(true);
+    messageBroker.clearStatusMessage();
+  }
+
+  private class Observer implements IndexObserver, DirectoryObserver {
+
+    @Override
+    public void openDirectory(LukeState state) {
+      tabbedPane.setEnabledAt(Tab.COMMITS.index(), true);
+    }
+
+    @Override
+    public void closeDirectory() {
+      tabbedPane.setEnabledAt(Tab.OVERVIEW.index(), false);
+      tabbedPane.setEnabledAt(Tab.DOCUMENTS.index(), false);
+      tabbedPane.setEnabledAt(Tab.SEARCH.index(), false);
+      tabbedPane.setEnabledAt(Tab.COMMITS.index(), false);
+    }
+
+    @Override
+    public void openIndex(LukeState state) {
+      tabbedPane.setEnabledAt(Tab.OVERVIEW.index(), true);
+      tabbedPane.setEnabledAt(Tab.DOCUMENTS.index(), true);
+      tabbedPane.setEnabledAt(Tab.SEARCH.index(), true);
+      tabbedPane.setEnabledAt(Tab.COMMITS.index(), true);
+    }
+
+    @Override
+    public void closeIndex() {
+      tabbedPane.setEnabledAt(Tab.OVERVIEW.index(), false);
+      tabbedPane.setEnabledAt(Tab.DOCUMENTS.index(), false);
+      tabbedPane.setEnabledAt(Tab.SEARCH.index(), false);
+      tabbedPane.setEnabledAt(Tab.COMMITS.index(), false);
+    }
+  }
+
+  /** tabs in the main frame */
+  public enum Tab {
+    OVERVIEW(0), DOCUMENTS(1), SEARCH(2), ANALYZER(3), COMMITS(4);
+
+    private int tabIdx;
+
+    Tab(int tabIdx) {
+      this.tabIdx = tabIdx;
+    }
+
+    int index() {
+      return tabIdx;
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableColumnInfo.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableColumnInfo.java
new file mode 100644
index 00000000000..63cdbb10700
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableColumnInfo.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+/** Holder of table column attributes */
+public interface TableColumnInfo {
+
+  String getColName();
+
+  int getIndex();
+
+  Class<?> getType();
+
+  default int getColumnWidth() {
+    return 0;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableModelBase.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableModelBase.java
new file mode 100644
index 00000000000..f8ef21a41ef
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/TableModelBase.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components;
+
+import javax.swing.table.AbstractTableModel;
+import java.util.Map;
+
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+
+/** Base table model that stores table's meta data and content. This also provides some default implementation of the {@link javax.swing.table.TableModel} interface. */
+public abstract class TableModelBase<T extends TableColumnInfo> extends AbstractTableModel {
+
+  private final Map<Integer, T> columnMap = TableUtils.columnMap(columnInfos());
+
+  private final String[] colNames = TableUtils.columnNames(columnInfos());
+
+  protected final Object[][] data;
+
+  protected TableModelBase() {
+    this.data = new Object[0][colNames.length];
+  }
+
+  protected TableModelBase(int rows) {
+    this.data = new Object[rows][colNames.length];
+  }
+
+  protected abstract T[] columnInfos();
+
+  @Override
+  public int getRowCount() {
+    return data.length;
+  }
+
+  @Override
+  public int getColumnCount() {
+    return colNames.length;
+  }
+
+  @Override
+  public String getColumnName(int colIndex) {
+    if (columnMap.containsKey(colIndex)) {
+      return columnMap.get(colIndex).getColName();
+    }
+    return "";
+  }
+
+  @Override
+  public Class<?> getColumnClass(int colIndex) {
+    if (columnMap.containsKey(colIndex)) {
+      return columnMap.get(colIndex).getType();
+    }
+    return Object.class;
+  }
+
+
+  @Override
+  public Object getValueAt(int rowIndex, int columnIndex) {
+    return data[rowIndex][columnIndex];
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/ConfirmDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/ConfirmDialogFactory.java
new file mode 100644
index 00000000000..d5465984edf
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/ConfirmDialogFactory.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.GridLayout;
+import java.awt.Window;
+import java.io.IOException;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.lang.Callable;
+
+/** Factory of confirm dialog */
+public final class ConfirmDialogFactory implements DialogOpener.DialogFactory {
+
+  private static ConfirmDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private String message;
+
+  private Callable callback;
+
+  public synchronized static ConfirmDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new ConfirmDialogFactory();
+    }
+    return instance;
+  }
+
+  private ConfirmDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setMessage(String message) {
+    this.message = message;
+  }
+
+  public void setCallback(Callable callback) {
+    this.callback = callback;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    JLabel alertIconLbl = new JLabel(FontUtils.elegantIconHtml("&#x71;"));
+    alertIconLbl.setHorizontalAlignment(JLabel.CENTER);
+    alertIconLbl.setFont(new Font(alertIconLbl.getFont().getFontName(), Font.PLAIN, 25));
+    header.add(alertIconLbl);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new GridLayout(1, 1));
+    center.setOpaque(false);
+    center.setBorder(BorderFactory.createLineBorder(Color.gray, 3));
+    center.add(new JLabel(message, JLabel.CENTER));
+    panel.add(center, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    footer.setOpaque(false);
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.addActionListener(e -> {
+      callback.call();
+      dialog.dispose();
+    });
+    footer.add(okBtn);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/HelpDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/HelpDialogFactory.java
new file mode 100644
index 00000000000..b9bcf9d2f78
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/HelpDialogFactory.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Window;
+import java.io.IOException;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Factory of help dialog */
+public final class HelpDialogFactory implements DialogOpener.DialogFactory {
+
+  private static HelpDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private String desc;
+
+  private JComponent helpContent;
+
+  public synchronized static HelpDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new HelpDialogFactory();
+    }
+    return instance;
+  }
+
+  private HelpDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setDesc(String desc) {
+    this.desc = desc;
+  }
+
+  public void setContent(JComponent helpContent) {
+    this.helpContent = helpContent;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(desc));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new GridLayout(1, 1));
+    center.setOpaque(false);
+    center.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+    center.add(helpContent);
+    panel.add(center, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    footer.setOpaque(false);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/AnalysisChainDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/AnalysisChainDialogFactory.java
new file mode 100644
index 00000000000..31fce6d05b3
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/AnalysisChainDialogFactory.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextField;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.io.IOException;
+
+import org.apache.lucene.analysis.custom.CustomAnalyzer;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Factory of analysis chain dialog */
+public class AnalysisChainDialogFactory implements DialogOpener.DialogFactory {
+
+  private static AnalysisChainDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private CustomAnalyzer analyzer;
+
+  public synchronized static AnalysisChainDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new AnalysisChainDialogFactory();
+    }
+    return instance;
+  }
+
+  private AnalysisChainDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setAnalyzer(CustomAnalyzer analyzer) {
+    this.analyzer = analyzer;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(analysisChain(), BorderLayout.PAGE_START);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 10, 5));
+    footer.setOpaque(false);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private JPanel analysisChain() {
+    JPanel panel = new JPanel(new GridBagLayout());
+    panel.setOpaque(false);
+
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.insets = new Insets(5, 5, 5, 5);
+
+    c.gridx = 0;
+    c.gridy = 0;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.dialog.chain.label.charfilters")), c);
+
+    String[] charFilters = analyzer.getCharFilterFactories().stream().map(f -> f.getClass().getName()).toArray(String[]::new);
+    JList<String> charFilterList = new JList<>(charFilters);
+    charFilterList.setVisibleRowCount(charFilters.length == 0 ? 1 : Math.min(charFilters.length, 5));
+    c.gridx = 1;
+    c.gridy = 0;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    panel.add(new JScrollPane(charFilterList), c);
+
+    c.gridx = 0;
+    c.gridy = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.1;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.dialog.chain.label.tokenizer")), c);
+
+    String tokenizer = analyzer.getTokenizerFactory().getClass().getName();
+    JTextField tokenizerTF = new JTextField(tokenizer);
+    tokenizerTF.setColumns(30);
+    tokenizerTF.setEditable(false);
+    tokenizerTF.setPreferredSize(new Dimension(300, 25));
+    tokenizerTF.setBorder(BorderFactory.createLineBorder(Color.gray));
+    c.gridx = 1;
+    c.gridy = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.1;
+    panel.add(tokenizerTF, c);
+
+    c.gridx = 0;
+    c.gridy = 2;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.dialog.chain.label.tokenfilters")), c);
+
+    String[] tokenFilters = analyzer.getTokenFilterFactories().stream().map(f -> f.getClass().getName()).toArray(String[]::new);
+    JList<String> tokenFilterList = new JList<>(tokenFilters);
+    tokenFilterList.setVisibleRowCount(tokenFilters.length == 0 ? 1 : Math.min(tokenFilters.length, 5));
+    tokenFilterList.setMinimumSize(new Dimension(300, 25));
+    c.gridx = 1;
+    c.gridy = 2;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    panel.add(new JScrollPane(tokenFilterList), c);
+
+    return panel;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersDialogFactory.java
new file mode 100644
index 00000000000..5a964d68397
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersDialogFactory.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import javax.swing.table.TableCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Window;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.CustomAnalyzerPanelOperator;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.app.desktop.util.lang.Callable;
+
+/** Factory of edit filters dialog */
+public final class EditFiltersDialogFactory implements DialogOpener.DialogFactory {
+
+  private static EditFiltersDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final EditParamsDialogFactory editParamsDialogFactory;
+
+  private final JLabel targetLbl = new JLabel();
+
+  private final JTable filtersTable = new JTable();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private final FiltersTableMouseListener tableListener = new FiltersTableMouseListener();
+
+  private JDialog dialog;
+
+  private List<String> selectedFilters;
+
+  private Callable callback;
+
+  private EditFiltersMode mode;
+
+  public synchronized static EditFiltersDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new EditFiltersDialogFactory();
+    }
+    return instance;
+  }
+
+  private EditFiltersDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.editParamsDialogFactory = EditParamsDialogFactory.getInstance();
+  }
+
+  public void setSelectedFilters(List<String> selectedFilters) {
+    this.selectedFilters = selectedFilters;
+  }
+
+  public void setCallback(Callable callback) {
+    this.callback = callback;
+  }
+
+  public void setMode(EditFiltersMode mode) {
+    this.mode = mode;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 10));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.dialog.hint.edit_param")));
+    header.add(targetLbl);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(filtersTable, ListSelectionModel.SINGLE_SELECTION, new FiltersTableModel(selectedFilters), tableListener,
+        FiltersTableModel.Column.DELETE.getColumnWidth(),
+        FiltersTableModel.Column.ORDER.getColumnWidth());
+    filtersTable.setShowGrid(true);
+    filtersTable.getColumnModel().getColumn(FiltersTableModel.Column.TYPE.getIndex()).setCellRenderer(new TypeCellRenderer());
+    panel.add(new JScrollPane(filtersTable), BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 10, 5));
+    footer.setOpaque(false);
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.addActionListener(e -> {
+      List<Integer> deletedIndexes = new ArrayList<>();
+      for (int i = 0; i < filtersTable.getRowCount(); i++) {
+        boolean deleted = (boolean) filtersTable.getValueAt(i, FiltersTableModel.Column.DELETE.getIndex());
+        if (deleted) {
+          deletedIndexes.add(i);
+        }
+      }
+      operatorRegistry.get(CustomAnalyzerPanelOperator.class).ifPresent(operator -> {
+        switch (mode) {
+          case CHARFILTER:
+            operator.updateCharFilters(deletedIndexes);
+            break;
+          case TOKENFILTER:
+            operator.updateTokenFilters(deletedIndexes);
+            break;
+        }
+      });
+      callback.call();
+      dialog.dispose();
+    });
+    footer.add(okBtn);
+    JButton cancelBtn = new JButton(MessageUtils.getLocalizedMessage("button.cancel"));
+    cancelBtn.addActionListener(e -> dialog.dispose());
+    footer.add(cancelBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private class ListenerFunctions {
+
+    void showEditParamsDialog(MouseEvent e) {
+      if (e.getClickCount() != 2 || e.isConsumed()) {
+        return;
+      }
+      int selectedIndex = filtersTable.rowAtPoint(e.getPoint());
+      if (selectedIndex < 0 || selectedIndex >= selectedFilters.size()) {
+        return;
+      }
+
+      switch (mode) {
+        case CHARFILTER:
+          showEditParamsCharFilterDialog(selectedIndex);
+          break;
+        case TOKENFILTER:
+          showEditParamsTokenFilterDialog(selectedIndex);
+          break;
+        default:
+      }
+    }
+
+    private void showEditParamsCharFilterDialog(int selectedIndex) {
+      int targetIndex = filtersTable.getSelectedRow();
+      String selectedItem = (String) filtersTable.getValueAt(selectedIndex, FiltersTableModel.Column.TYPE.getIndex());
+      Map<String, String> params = operatorRegistry.get(CustomAnalyzerPanelOperator.class).map(operator -> operator.getCharFilterParams(targetIndex)).orElse(Collections.emptyMap());
+      new DialogOpener<>(editParamsDialogFactory).open(dialog, MessageUtils.getLocalizedMessage("analysis.dialog.title.char_filter_params"), 400, 300,
+          factory -> {
+            factory.setMode(EditParamsMode.CHARFILTER);
+            factory.setTargetIndex(targetIndex);
+            factory.setTarget(selectedItem);
+            factory.setParams(params);
+          });
+    }
+
+    private void showEditParamsTokenFilterDialog(int selectedIndex) {
+      int targetIndex = filtersTable.getSelectedRow();
+      String selectedItem = (String) filtersTable.getValueAt(selectedIndex, FiltersTableModel.Column.TYPE.getIndex());
+      Map<String, String> params = operatorRegistry.get(CustomAnalyzerPanelOperator.class).map(operator -> operator.getTokenFilterParams(targetIndex)).orElse(Collections.emptyMap());
+      new DialogOpener<>(editParamsDialogFactory).open(dialog, MessageUtils.getLocalizedMessage("analysis.dialog.title.char_filter_params"), 400, 300,
+          factory -> {
+            factory.setMode(EditParamsMode.TOKENFILTER);
+            factory.setTargetIndex(targetIndex);
+            factory.setTarget(selectedItem);
+            factory.setParams(params);
+          });
+    }
+  }
+
+  private class FiltersTableMouseListener extends MouseAdapter {
+    @Override
+    public void mouseClicked(MouseEvent e) {
+      listeners.showEditParamsDialog(e);
+    }
+  }
+
+  static final class FiltersTableModel extends TableModelBase<FiltersTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      DELETE("Delete", 0, Boolean.class, 50),
+      ORDER("Order", 1, Integer.class, 50),
+      TYPE("Factory class", 2, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    FiltersTableModel() {
+      super();
+    }
+
+    FiltersTableModel(List<String> selectedFilters) {
+      super(selectedFilters.size());
+      for (int i = 0; i < selectedFilters.size(); i++) {
+        data[i][Column.DELETE.getIndex()] = false;
+        data[i][Column.ORDER.getIndex()] = i + 1;
+        data[i][Column.TYPE.getIndex()] = selectedFilters.get(i);
+      }
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return columnIndex == Column.DELETE.getIndex();
+    }
+
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class TypeCellRenderer implements TableCellRenderer {
+
+    @Override
+    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+      String[] tmp = ((String) value).split("\\.");
+      String type = tmp[tmp.length - 1];
+      return new JLabel(type);
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersMode.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersMode.java
new file mode 100644
index 00000000000..d5edd8b505e
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditFiltersMode.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+/** Edit filters mode */
+public enum EditFiltersMode {
+  CHARFILTER, TOKENFILTER;
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsDialogFactory.java
new file mode 100644
index 00000000000..f9a30da8cd2
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsDialogFactory.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Window;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.components.fragments.analysis.CustomAnalyzerPanelOperator;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.app.desktop.util.lang.Callable;
+
+/** Factory of edit parameters dialog */
+public final class EditParamsDialogFactory implements DialogOpener.DialogFactory {
+
+  private static EditParamsDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final JTable paramsTable = new JTable();
+
+  private JDialog dialog;
+
+  private EditParamsMode mode;
+
+  private String target;
+
+  private int targetIndex;
+
+  private Map<String, String> params = new HashMap<>();
+
+  private Callable callback;
+
+  public synchronized static EditParamsDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new EditParamsDialogFactory();
+    }
+    return instance;
+  }
+
+  private EditParamsDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+  }
+
+  public void setMode(EditParamsMode mode) {
+    this.mode = mode;
+  }
+
+  public void setTarget(String target) {
+    this.target = target;
+  }
+
+  public void setTargetIndex(int targetIndex) {
+    this.targetIndex = targetIndex;
+  }
+
+  public void setParams(Map<String, String> params) {
+    this.params.putAll(params);
+  }
+
+  public void setCallback(Callable callback) {
+    this.callback = callback;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 10));
+    header.setOpaque(false);
+    header.add(new JLabel("Parameters for:"));
+    String[] tmp = target.split("\\.");
+    JLabel targetLbl = new JLabel(tmp[tmp.length - 1]);
+    header.add(targetLbl);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(paramsTable, ListSelectionModel.SINGLE_SELECTION, new ParamsTableModel(params), null,
+        ParamsTableModel.Column.DELETE.getColumnWidth(),
+        ParamsTableModel.Column.NAME.getColumnWidth());
+    paramsTable.setShowGrid(true);
+    panel.add(new JScrollPane(paramsTable), BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 10, 5));
+    footer.setOpaque(false);
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.addActionListener(e -> {
+      Map<String, String> params = new HashMap<>();
+      for (int i = 0; i < paramsTable.getRowCount(); i++) {
+        boolean deleted = (boolean) paramsTable.getValueAt(i, ParamsTableModel.Column.DELETE.getIndex());
+        String name = (String) paramsTable.getValueAt(i, ParamsTableModel.Column.NAME.getIndex());
+        String value = (String) paramsTable.getValueAt(i, ParamsTableModel.Column.VALUE.getIndex());
+        if (deleted || Objects.isNull(name) || name.equals("") || Objects.isNull(value) || value.equals("")) {
+          continue;
+        }
+        params.put(name, value);
+      }
+      updateTargetParams(params);
+      callback.call();
+      this.params.clear();
+      dialog.dispose();
+    });
+    footer.add(okBtn);
+    JButton cancelBtn = new JButton(MessageUtils.getLocalizedMessage("button.cancel"));
+    cancelBtn.addActionListener(e -> {
+      this.params.clear();
+      dialog.dispose();
+    });
+    footer.add(cancelBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private void updateTargetParams(Map<String, String> params) {
+    operatorRegistry.get(CustomAnalyzerPanelOperator.class).ifPresent(operator -> {
+      switch (mode) {
+        case CHARFILTER:
+          operator.updateCharFilterParams(targetIndex, params);
+          break;
+        case TOKENIZER:
+          operator.updateTokenizerParams(params);
+          break;
+        case TOKENFILTER:
+          operator.updateTokenFilterParams(targetIndex, params);
+          break;
+      }
+    });
+  }
+
+  static final class ParamsTableModel extends TableModelBase<ParamsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      DELETE("Delete", 0, Boolean.class, 50),
+      NAME("Name", 1, String.class, 150),
+      VALUE("Value", 2, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+
+    }
+
+    private static final int PARAM_SIZE = 20;
+
+    ParamsTableModel(Map<String, String> params) {
+      super(PARAM_SIZE);
+      List<String> keys = new ArrayList<>(params.keySet());
+      for (int i = 0; i < keys.size(); i++) {
+        data[i][Column.NAME.getIndex()] = keys.get(i);
+        data[i][Column.VALUE.getIndex()] = params.get(keys.get(i));
+      }
+      for (int i = 0; i < data.length; i++) {
+        data[i][Column.DELETE.getIndex()] = false;
+      }
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return true;
+    }
+
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsMode.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsMode.java
new file mode 100644
index 00000000000..8e76879dc22
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/EditParamsMode.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+/** Edit parameters mode */
+public enum EditParamsMode {
+  CHARFILTER, TOKENIZER, TOKENFILTER;
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/TokenAttributeDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/TokenAttributeDialogFactory.java
new file mode 100644
index 00000000000..4112699754f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/TokenAttributeDialogFactory.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Window;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.analysis.Analysis;
+
+/** Factory of token attribute dialog */
+public final class TokenAttributeDialogFactory implements DialogOpener.DialogFactory {
+
+  private static TokenAttributeDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final JTable attributesTable = new JTable();
+
+  private JDialog dialog;
+
+  private String term;
+
+  private List<Analysis.TokenAttribute> attributes;
+
+  public synchronized static TokenAttributeDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new TokenAttributeDialogFactory();
+    }
+    return instance;
+  }
+
+  private TokenAttributeDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setTerm(String term) {
+    this.term = term;
+  }
+
+  public void setAttributes(List<Analysis.TokenAttribute> attributes) {
+    this.attributes = attributes;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel("All token attributes for:"));
+    header.add(new JLabel(term));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    List<TokenAttValue> attrValues = attributes.stream()
+        .flatMap(att -> att.getAttValues().entrySet().stream().map(e -> TokenAttValue.of(att.getAttClass(), e.getKey(), e.getValue())))
+        .collect(Collectors.toList());
+    TableUtils.setupTable(attributesTable, ListSelectionModel.SINGLE_SELECTION, new AttributeTableModel(attrValues), null);
+    panel.add(new JScrollPane(attributesTable), BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    footer.setOpaque(false);
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.addActionListener(e -> dialog.dispose());
+    footer.add(okBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  static final class AttributeTableModel extends TableModelBase<AttributeTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      ATTR("Attribute", 0, String.class),
+      NAME("Name", 1, String.class),
+      VALUE("Value", 2, String.class);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+
+      Column(String colName, int index, Class<?> type) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+    }
+
+    AttributeTableModel(List<TokenAttValue> attrValues) {
+      super(attrValues.size());
+      for (int i = 0; i < attrValues.size(); i++) {
+        TokenAttValue attrValue = attrValues.get(i);
+        data[i][Column.ATTR.getIndex()] = attrValue.getAttClass();
+        data[i][Column.NAME.getIndex()] = attrValue.getName();
+        data[i][Column.VALUE.getIndex()] = attrValue.getValue();
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class TokenAttValue {
+    private String attClass;
+    private String name;
+    private String value;
+
+    public static TokenAttValue of(String attClass, String name, String value) {
+      TokenAttValue attValue = new TokenAttValue();
+      attValue.attClass = attClass;
+      attValue.name = name;
+      attValue.value = value;
+      return attValue;
+    }
+
+    private TokenAttValue() {
+    }
+
+    String getAttClass() {
+      return attClass;
+    }
+
+    String getName() {
+      return name;
+    }
+
+    String getValue() {
+      return value;
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/package-info.java
new file mode 100644
index 00000000000..bd3419bd66f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/analysis/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Dialogs used in the Analysis tab */
+package org.apache.lucene.luke.app.desktop.components.dialog.analysis;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogFactory.java
new file mode 100644
index 00000000000..0bbeb3eb6f5
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogFactory.java
@@ -0,0 +1,593 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.DefaultCellEditor;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.JTextArea;
+import javax.swing.ListSelectionModel;
+import javax.swing.UIManager;
+import javax.swing.table.JTableHeader;
+import javax.swing.table.TableCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Constructor;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.DoublePoint;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FloatPoint;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedDocValuesField;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.IndexableFieldType;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.AnalysisTabOperator;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.DocumentsTabOperator;
+import org.apache.lucene.luke.app.desktop.components.TabSwitcherProxy;
+import org.apache.lucene.luke.app.desktop.components.TabbedPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.components.dialog.HelpDialogFactory;
+import org.apache.lucene.luke.app.desktop.dto.documents.NewField;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.HelpHeaderRenderer;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.NumericUtils;
+import org.apache.lucene.luke.app.desktop.util.StringUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.tools.IndexTools;
+import org.apache.lucene.luke.models.tools.IndexToolsFactory;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.util.BytesRef;
+
+/** Factory of add document dialog */
+public final class AddDocumentDialogFactory implements DialogOpener.DialogFactory, AddDocumentDialogOperator {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static AddDocumentDialogFactory instance;
+
+  private final static int ROW_COUNT = 50;
+
+  private final Preferences prefs;
+
+  private final IndexHandler indexHandler;
+
+  private final IndexToolsFactory toolsFactory = new IndexToolsFactory();
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final IndexOptionsDialogFactory indexOptionsDialogFactory;
+
+  private final HelpDialogFactory helpDialogFactory;
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private final JLabel analyzerNameLbl = new JLabel(StandardAnalyzer.class.getName());
+
+  private final List<NewField> newFieldList;
+
+  private final JButton addBtn = new JButton();
+
+  private final JButton closeBtn = new JButton();
+
+  private final JTextArea infoTA = new JTextArea();
+
+  private IndexTools toolsModel;
+
+  private JDialog dialog;
+
+  public synchronized static AddDocumentDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new AddDocumentDialogFactory();
+    }
+    return  instance;
+  }
+
+  private AddDocumentDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.indexHandler = IndexHandler.getInstance();
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.indexOptionsDialogFactory = IndexOptionsDialogFactory.getInstance();
+    this.helpDialogFactory = HelpDialogFactory.getInstance();
+    this.newFieldList = IntStream.range(0, ROW_COUNT).mapToObj(i -> NewField.newInstance()).collect(Collectors.toList());
+
+    operatorRegistry.register(AddDocumentDialogOperator.class, this);
+    indexHandler.addObserver(new Observer());
+
+    initialize();
+  }
+
+  private void initialize() {
+    addBtn.setText(MessageUtils.getLocalizedMessage("add_document.button.add"));
+    addBtn.setMargin(new Insets(3, 3, 3, 3));
+    addBtn.setEnabled(true);
+    addBtn.addActionListener(listeners::addDocument);
+
+    closeBtn.setText(MessageUtils.getLocalizedMessage("button.cancel"));
+    closeBtn.setMargin(new Insets(3, 3, 3, 3));
+    closeBtn.addActionListener(e -> dialog.dispose());
+
+    infoTA.setRows(3);
+    infoTA.setLineWrap(true);
+    infoTA.setEditable(false);
+    infoTA.setText(MessageUtils.getLocalizedMessage("add_document.info"));
+    infoTA.setForeground(Color.gray);
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+    panel.add(header(), BorderLayout.PAGE_START);
+    panel.add(center(), BorderLayout.CENTER);
+    panel.add(footer(), BorderLayout.PAGE_END);
+    return panel;
+  }
+
+  private JPanel header() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel analyzerHeader = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 10));
+    analyzerHeader.setOpaque(false);
+    analyzerHeader.add(new JLabel(MessageUtils.getLocalizedMessage("add_document.label.analyzer")));
+    analyzerHeader.add(analyzerNameLbl);
+    JLabel changeLbl = new JLabel(MessageUtils.getLocalizedMessage("add_document.hyperlink.change"));
+    changeLbl.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        dialog.dispose();
+        tabSwitcher.switchTab(TabbedPaneProvider.Tab.ANALYZER);
+      }
+    });
+    analyzerHeader.add(FontUtils.toLinkText(changeLbl));
+    panel.add(analyzerHeader);
+
+    return panel;
+  }
+
+  private JPanel center() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+
+    JPanel tableHeader = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 5));
+    tableHeader.setOpaque(false);
+    tableHeader.add(new JLabel(MessageUtils.getLocalizedMessage("add_document.label.fields")));
+    panel.add(tableHeader, BorderLayout.PAGE_START);
+
+    JScrollPane scrollPane = new JScrollPane(fieldsTable());
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    JPanel tableFooter = new JPanel(new FlowLayout(FlowLayout.TRAILING, 10, 5));
+    tableFooter.setOpaque(false);
+    addBtn.setEnabled(true);
+    tableFooter.add(addBtn);
+    tableFooter.add(closeBtn);
+    panel.add(tableFooter, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private JTable fieldsTable() {
+    JTable fieldsTable = new JTable();
+    TableUtils.setupTable(fieldsTable, ListSelectionModel.SINGLE_SELECTION, new FieldsTableModel(newFieldList), null, 30, 150, 120, 80);
+    fieldsTable.setShowGrid(true);
+    JComboBox<Class<? extends IndexableField>> typesCombo = new JComboBox<>(presetFieldClasses);
+    typesCombo.setRenderer((list, value, index, isSelected, cellHasFocus) -> new JLabel(value.getSimpleName()));
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.TYPE.getIndex()).setCellEditor(new DefaultCellEditor(typesCombo));
+    for (int i = 0; i < fieldsTable.getModel().getRowCount(); i++) {
+      fieldsTable.getModel().setValueAt(TextField.class, i, FieldsTableModel.Column.TYPE.getIndex());
+    }
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.TYPE.getIndex()).setHeaderRenderer(
+        new HelpHeaderRenderer(
+            "About Type", "Select Field Class:",
+            createTypeHelpDialog(), helpDialogFactory, dialog));
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.TYPE.getIndex()).setCellRenderer(new TypeCellRenderer());
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.OPTIONS.getIndex()).setCellRenderer(new OptionsCellRenderer(dialog, indexOptionsDialogFactory, newFieldList));
+    return fieldsTable;
+  }
+
+  private JComponent createTypeHelpDialog() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JTextArea descTA = new JTextArea();
+
+    JPanel header = new JPanel();
+    header.setOpaque(false);
+    header.setLayout(new BoxLayout(header, BoxLayout.PAGE_AXIS));
+    String[] typeList = new String[]{
+        "TextField",
+        "StringField",
+        "IntPoint",
+        "LongPoint",
+        "FloatPoint",
+        "DoublePoint",
+        "SortedDocValuesField",
+        "SortedSetDocValuesField",
+        "NumericDocValuesField",
+        "SortedNumericDocValuesField",
+        "StoredField",
+        "Field"
+    };
+    JPanel wrapper1 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    wrapper1.setOpaque(false);
+    JComboBox<String> typeCombo = new JComboBox<>(typeList);
+    typeCombo.setSelectedItem(typeList[0]);
+    typeCombo.addActionListener(e -> {
+      String selected = (String) typeCombo.getSelectedItem();
+      descTA.setText(MessageUtils.getLocalizedMessage("help.fieldtype." + selected));
+    });
+    wrapper1.add(typeCombo);
+    header.add(wrapper1);
+    JPanel wrapper2 = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    wrapper2.setOpaque(false);
+    wrapper2.add(new JLabel("Brief description and Examples"));
+    header.add(wrapper2);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    descTA.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+    descTA.setEditable(false);
+    descTA.setLineWrap(true);
+    descTA.setRows(10);
+    descTA.setText(MessageUtils.getLocalizedMessage("help.fieldtype." + typeList[0]));
+    JScrollPane scrollPane = new JScrollPane(descTA);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel footer() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+
+    JScrollPane scrollPane = new JScrollPane(infoTA);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    panel.add(scrollPane);
+    return panel;
+  }
+
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  private final Class<? extends IndexableField>[] presetFieldClasses = new Class[]{
+      TextField.class, StringField.class,
+      IntPoint.class, LongPoint.class, FloatPoint.class, DoublePoint.class,
+      SortedDocValuesField.class, SortedSetDocValuesField.class,
+      NumericDocValuesField.class, SortedNumericDocValuesField.class,
+      StoredField.class, Field.class
+  };
+
+  @Override
+  public void setAnalyzer(Analyzer analyzer) {
+    analyzerNameLbl.setText(analyzer.getClass().getName());
+  }
+
+  private class ListenerFunctions {
+
+    void addDocument(ActionEvent e) {
+      List<NewField> validFields = newFieldList.stream()
+          .filter(nf -> !nf.isDeleted())
+          .filter(nf -> !StringUtils.isNullOrEmpty(nf.getName()))
+          .filter(nf -> !StringUtils.isNullOrEmpty(nf.getValue()))
+          .collect(Collectors.toList());
+      if (validFields.isEmpty()) {
+        infoTA.setText("Please add one or more fields. Name and Value are both required.");
+        return;
+      }
+
+      Document doc = new Document();
+      try {
+        for (NewField nf : validFields) {
+          doc.add(toIndexableField(nf));
+        }
+      } catch (NumberFormatException ex) {
+        log.error(ex.getMessage(), e);
+        throw new LukeException("Invalid value: " + ex.getMessage(), ex);
+      } catch (Exception ex) {
+        log.error(ex.getMessage(), e);
+        throw new LukeException(ex.getMessage(), ex);
+      }
+
+      addDocument(doc);
+      log.info("Added document: {}", doc.toString());
+    }
+
+    @SuppressWarnings("unchecked")
+    private IndexableField toIndexableField(NewField nf) throws Exception {
+      final Constructor<? extends IndexableField> constr;
+      if (nf.getType().equals(TextField.class) || nf.getType().equals(StringField.class)) {
+        Field.Store store = nf.isStored() ? Field.Store.YES : Field.Store.NO;
+        constr = nf.getType().getConstructor(String.class, String.class, Field.Store.class);
+        return constr.newInstance(nf.getName(), nf.getValue(), store);
+      } else if (nf.getType().equals(IntPoint.class)) {
+        constr = nf.getType().getConstructor(String.class, int[].class);
+        int[] values = NumericUtils.convertToIntArray(nf.getValue(), false);
+        return constr.newInstance(nf.getName(), values);
+      } else if (nf.getType().equals(LongPoint.class)) {
+        constr = nf.getType().getConstructor(String.class, long[].class);
+        long[] values = NumericUtils.convertToLongArray(nf.getValue(), false);
+        return constr.newInstance(nf.getName(), values);
+      } else if (nf.getType().equals(FloatPoint.class)) {
+        constr = nf.getType().getConstructor(String.class, float[].class);
+        float[] values = NumericUtils.convertToFloatArray(nf.getValue(), false);
+        return constr.newInstance(nf.getName(), values);
+      } else if (nf.getType().equals(DoublePoint.class)) {
+        constr = nf.getType().getConstructor(String.class, double[].class);
+        double[] values = NumericUtils.convertToDoubleArray(nf.getValue(), false);
+        return constr.newInstance(nf.getName(), values);
+      } else if (nf.getType().equals(SortedDocValuesField.class) ||
+          nf.getType().equals(SortedSetDocValuesField.class)) {
+        constr = nf.getType().getConstructor(String.class, BytesRef.class);
+        return constr.newInstance(nf.getName(), new BytesRef(nf.getValue()));
+      } else if (nf.getType().equals(NumericDocValuesField.class) ||
+          nf.getType().equals(SortedNumericDocValuesField.class)) {
+        constr = nf.getType().getConstructor(String.class, long.class);
+        long value = NumericUtils.tryConvertToLongValue(nf.getValue());
+        return constr.newInstance(nf.getName(), value);
+      } else if (nf.getType().equals(StoredField.class)) {
+        constr = nf.getType().getConstructor(String.class, String.class);
+        return constr.newInstance(nf.getName(), nf.getValue());
+      } else if (nf.getType().equals(Field.class)) {
+        constr = nf.getType().getConstructor(String.class, String.class, IndexableFieldType.class);
+        return constr.newInstance(nf.getName(), nf.getValue(), nf.getFieldType());
+      } else {
+        // TODO: unknown field
+        return new StringField(nf.getName(), nf.getValue(), Field.Store.YES);
+      }
+    }
+
+    private void addDocument(Document doc) {
+      try {
+        Analyzer analyzer = operatorRegistry.get(AnalysisTabOperator.class)
+            .map(AnalysisTabOperator::getCurrentAnalyzer)
+            .orElse(new StandardAnalyzer());
+        toolsModel.addDocument(doc, analyzer);
+        indexHandler.reOpen();
+        operatorRegistry.get(DocumentsTabOperator.class).ifPresent(DocumentsTabOperator::displayLatestDoc);
+        tabSwitcher.switchTab(TabbedPaneProvider.Tab.DOCUMENTS);
+        infoTA.setText(MessageUtils.getLocalizedMessage("add_document.message.success"));
+        addBtn.setEnabled(false);
+        closeBtn.setText(MessageUtils.getLocalizedMessage("button.close"));
+      } catch (LukeException e) {
+        infoTA.setText(MessageUtils.getLocalizedMessage("add_document.message.fail"));
+        throw e;
+      } catch (Exception e) {
+        infoTA.setText(MessageUtils.getLocalizedMessage("add_document.message.fail"));
+        throw new LukeException(e.getMessage(), e);
+      }
+    }
+
+  }
+
+  private class Observer implements IndexObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      toolsModel = toolsFactory.newInstance(state.getIndexReader(), state.useCompound(), state.keepAllCommits());
+    }
+
+    @Override
+    public void closeIndex() {
+      toolsModel = null;
+    }
+  }
+
+  static final class FieldsTableModel extends TableModelBase<FieldsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      DEL("Del", 0, Boolean.class),
+      NAME("Name", 1, String.class),
+      TYPE("Type", 2, Class.class),
+      OPTIONS("Options", 3, String.class),
+      VALUE("Value", 4, String.class);
+
+      private String colName;
+      private int index;
+      private Class<?> type;
+
+      Column(String colName, int index, Class<?> type) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+    }
+
+    private final List<NewField> newFieldList;
+
+    FieldsTableModel(List<NewField> newFieldList) {
+      super(newFieldList.size());
+      this.newFieldList = newFieldList;
+    }
+
+    @Override
+    public Object getValueAt(int rowIndex, int columnIndex) {
+      if (columnIndex == Column.OPTIONS.getIndex()) {
+        return "";
+      }
+      return data[rowIndex][columnIndex];
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return columnIndex != Column.OPTIONS.getIndex();
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+      fireTableCellUpdated(rowIndex, columnIndex);
+      NewField selectedField = newFieldList.get(rowIndex);
+      if (columnIndex == Column.DEL.getIndex()) {
+        selectedField.setDeleted((Boolean) value);
+      } else if (columnIndex == Column.NAME.getIndex()) {
+        selectedField.setName((String) value);
+      } else if (columnIndex == Column.TYPE.getIndex()) {
+        selectedField.setType((Class<? extends IndexableField>) value);
+        selectedField.resetFieldType((Class<? extends IndexableField>) value);
+        selectedField.setStored(selectedField.getFieldType().stored());
+      } else if (columnIndex == Column.VALUE.getIndex()) {
+        selectedField.setValue((String) value);
+      }
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+  static final class TypeCellRenderer implements TableCellRenderer {
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+      String simpleName = ((Class<? extends IndexableField>) value).getSimpleName();
+      return new JLabel(simpleName);
+    }
+  }
+
+  static final class OptionsCellRenderer implements TableCellRenderer {
+
+    private JDialog dialog;
+
+    private final IndexOptionsDialogFactory indexOptionsDialogFactory;
+
+    private final List<NewField> newFieldList;
+
+    private final JPanel panel = new JPanel();
+
+    private JTable table;
+
+    public OptionsCellRenderer(JDialog dialog, IndexOptionsDialogFactory indexOptionsDialogFactory, List<NewField> newFieldList) {
+      this.dialog = dialog;
+      this.indexOptionsDialogFactory = indexOptionsDialogFactory;
+      this.newFieldList = newFieldList;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+      if (table != null && this.table != table) {
+        this.table = table;
+        final JTableHeader header = table.getTableHeader();
+        if (header != null) {
+          panel.setLayout(new FlowLayout(FlowLayout.CENTER, 0, 0));
+          panel.setBorder(UIManager.getBorder("TableHeader.cellBorder"));
+          panel.add(new JLabel(value.toString()));
+
+          JLabel optionsLbl = new JLabel("options");
+          table.addMouseListener(new MouseAdapter() {
+            @Override
+            public void mouseClicked(MouseEvent e) {
+              int row = table.rowAtPoint(e.getPoint());
+              int col = table.columnAtPoint(e.getPoint());
+              if (row >= 0 && col == FieldsTableModel.Column.OPTIONS.getIndex()) {
+                String title = "Index options for:";
+                new DialogOpener<>(indexOptionsDialogFactory).open(dialog, title, 500, 500,
+                    (factory) -> {
+                      factory.setNewField(newFieldList.get(row));
+                    });
+              }
+            }
+          });
+          panel.add(FontUtils.toLinkText(optionsLbl));
+        }
+      }
+      return panel;
+    }
+
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogOperator.java
new file mode 100644
index 00000000000..2c29d6fd5db
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/AddDocumentDialogOperator.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+
+/** Operator of add dodument dialog */
+public interface AddDocumentDialogOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setAnalyzer(Analyzer analyzer);
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/DocValuesDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/DocValuesDialogFactory.java
new file mode 100644
index 00000000000..7bea476a606
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/DocValuesDialogFactory.java
@@ -0,0 +1,296 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.DefaultComboBoxModel;
+import javax.swing.DefaultListModel;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Insets;
+import java.awt.Toolkit;
+import java.awt.Window;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.models.documents.DocValues;
+import org.apache.lucene.luke.util.BytesRefUtils;
+import org.apache.lucene.util.NumericUtils;
+
+/** Factory of doc values dialog */
+public final class DocValuesDialogFactory implements DialogOpener.DialogFactory {
+
+  private static DocValuesDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final JComboBox<String> decodersCombo = new JComboBox<>();
+
+  private final JList<String> valueList = new JList<>();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private JDialog dialog;
+
+  private String field;
+
+  private DocValues docValues;
+
+  public synchronized static DocValuesDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new DocValuesDialogFactory();
+    }
+    return instance;
+  }
+
+  private DocValuesDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setValue(String field, DocValues docValues) {
+    this.field = field;
+    this.docValues = docValues;
+
+    DefaultListModel<String> values = new DefaultListModel<>();
+    if (docValues.getValues().size() > 0) {
+      decodersCombo.setEnabled(false);
+      docValues.getValues().stream()
+          .map(BytesRefUtils::decode)
+          .forEach(values::addElement);
+    } else if (docValues.getNumericValues().size() > 0) {
+      decodersCombo.setEnabled(true);
+      docValues.getNumericValues().stream()
+          .map(String::valueOf)
+          .forEach(values::addElement);
+    }
+
+    valueList.setModel(values);
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    if (Objects.isNull(field) || Objects.isNull(docValues)) {
+      throw new IllegalStateException("field name and/or doc values is not set.");
+    }
+
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+    panel.add(headerPanel(), BorderLayout.PAGE_START);
+    JScrollPane scrollPane = new JScrollPane(valueList());
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    panel.add(scrollPane, BorderLayout.CENTER);
+    panel.add(footerPanel(), BorderLayout.PAGE_END);
+    return panel;
+  }
+
+  private JPanel headerPanel() {
+    JPanel header = new JPanel();
+    header.setOpaque(false);
+    header.setLayout(new BoxLayout(header, BoxLayout.PAGE_AXIS));
+
+    JPanel fieldHeader = new JPanel(new FlowLayout(FlowLayout.LEADING, 3, 3));
+    fieldHeader.setOpaque(false);
+    fieldHeader.add(new JLabel(MessageUtils.getLocalizedMessage("documents.docvalues.label.doc_values")));
+    fieldHeader.add(new JLabel(field));
+    header.add(fieldHeader);
+
+    JPanel typeHeader = new JPanel(new FlowLayout(FlowLayout.LEADING, 3, 3));
+    typeHeader.setOpaque(false);
+    typeHeader.add(new JLabel(MessageUtils.getLocalizedMessage("documents.docvalues.label.type")));
+    typeHeader.add(new JLabel(docValues.getDvType().toString()));
+    header.add(typeHeader);
+
+    JPanel decodeHeader = new JPanel(new FlowLayout(FlowLayout.TRAILING, 3, 3));
+    decodeHeader.setOpaque(false);
+    decodeHeader.add(new JLabel("decoded as"));
+    String[] decoders = Arrays.stream(Decoder.values()).map(Decoder::toString).toArray(String[]::new);
+    decodersCombo.setModel(new DefaultComboBoxModel<>(decoders));
+    decodersCombo.setSelectedItem(Decoder.LONG.toString());
+    decodersCombo.addActionListener(listeners::selectDecoder);
+    decodeHeader.add(decodersCombo);
+    if (docValues.getValues().size() > 0) {
+      decodeHeader.setEnabled(false);
+    }
+    header.add(decodeHeader);
+
+    return header;
+  }
+
+  private JList<String> valueList() {
+    valueList.setVisibleRowCount(5);
+    valueList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
+    valueList.setLayoutOrientation(JList.VERTICAL);
+
+    DefaultListModel<String> values = new DefaultListModel<>();
+    if (docValues.getValues().size() > 0) {
+      docValues.getValues().stream()
+          .map(BytesRefUtils::decode)
+          .forEach(values::addElement);
+    } else {
+      docValues.getNumericValues().stream()
+          .map(String::valueOf)
+          .forEach(values::addElement);
+    }
+    valueList.setModel(values);
+
+    return valueList;
+  }
+
+  private JPanel footerPanel() {
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 5, 5));
+    footer.setOpaque(false);
+
+    JButton copyBtn = new JButton(FontUtils.elegantIconHtml("&#xe0e6;", MessageUtils.getLocalizedMessage("button.copy")));
+    copyBtn.setMargin(new Insets(3, 0, 3, 0));
+    copyBtn.addActionListener(listeners::copyValues);
+    footer.add(copyBtn);
+
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setMargin(new Insets(3, 0, 3, 0));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+
+    return footer;
+  }
+
+  // control methods
+
+  private void selectDecoder() {
+    String decoderLabel = (String) decodersCombo.getSelectedItem();
+    Decoder decoder = Decoder.fromLabel(decoderLabel);
+
+    if (docValues.getNumericValues().isEmpty()) {
+      return;
+    }
+
+    DefaultListModel<String> values = new DefaultListModel<>();
+    switch (decoder) {
+      case LONG:
+        docValues.getNumericValues().stream()
+            .map(String::valueOf)
+            .forEach(values::addElement);
+        break;
+      case FLOAT:
+        docValues.getNumericValues().stream()
+            .mapToInt(Long::intValue)
+            .mapToObj(NumericUtils::sortableIntToFloat)
+            .map(String::valueOf)
+            .forEach(values::addElement);
+        break;
+      case DOUBLE:
+        docValues.getNumericValues().stream()
+            .map(NumericUtils::sortableLongToDouble)
+            .map(String::valueOf)
+            .forEach(values::addElement);
+        break;
+    }
+
+    valueList.setModel(values);
+  }
+
+  private void copyValues() {
+    List<String> values = valueList.getSelectedValuesList();
+    if (values.isEmpty()) {
+      values = getAllVlues();
+    }
+
+    Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+    StringSelection selection = new StringSelection(String.join("\n", values));
+    clipboard.setContents(selection, null);
+  }
+
+  private List<String> getAllVlues() {
+    List<String> values = new ArrayList<>();
+    for (int i = 0; i < valueList.getModel().getSize(); i++) {
+      values.add(valueList.getModel().getElementAt(i));
+    }
+    return values;
+  }
+
+  private class ListenerFunctions {
+
+    void selectDecoder(ActionEvent e) {
+      DocValuesDialogFactory.this.selectDecoder();
+    }
+
+    void copyValues(ActionEvent e) {
+      DocValuesDialogFactory.this.copyValues();
+    }
+  }
+
+
+  /** doc value decoders */
+  public enum Decoder {
+
+    LONG("long"), FLOAT("float"), DOUBLE("double");
+
+    private final String label;
+
+    Decoder(String label) {
+      this.label = label;
+    }
+
+    @Override
+    public String toString() {
+      return label;
+    }
+
+    public static Decoder fromLabel(String label) {
+      for (Decoder d : values()) {
+        if (d.label.equalsIgnoreCase(label)) {
+          return d;
+        }
+      }
+      throw new IllegalArgumentException("No such decoder: " + label);
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/IndexOptionsDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/IndexOptionsDialogFactory.java
new file mode 100644
index 00000000000..a0bda9cd973
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/IndexOptionsDialogFactory.java
@@ -0,0 +1,308 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.JTextField;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.io.IOException;
+import java.util.Arrays;
+
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.IndexableFieldType;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.dto.documents.NewField;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Factory of index options dialog */
+public final class IndexOptionsDialogFactory implements DialogOpener.DialogFactory {
+
+  private static IndexOptionsDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final JCheckBox storedCB = new JCheckBox();
+
+  private final JCheckBox tokenizedCB = new JCheckBox();
+
+  private final JCheckBox omitNormsCB = new JCheckBox();
+
+  private final JComboBox<String> idxOptCombo = new JComboBox<>(availableIndexOptions());
+
+  private final JCheckBox storeTVCB = new JCheckBox();
+
+  private final JCheckBox storeTVPosCB = new JCheckBox();
+
+  private final JCheckBox storeTVOffCB = new JCheckBox();
+
+  private final JCheckBox storeTVPayCB = new JCheckBox();
+
+  private final JComboBox<String> dvTypeCombo = new JComboBox<>(availableDocValuesType());
+
+  private final JTextField dimCountTF = new JTextField();
+
+  private final JTextField dimNumBytesTF = new JTextField();
+
+  private JDialog dialog;
+
+  private NewField nf;
+
+  public synchronized static IndexOptionsDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new IndexOptionsDialogFactory();
+    }
+    return instance;
+  }
+
+  private IndexOptionsDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    initialize();
+  }
+
+  private void initialize() {
+    storedCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.stored"));
+    storedCB.setOpaque(false);
+    tokenizedCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.tokenized"));
+    tokenizedCB.setOpaque(false);
+    omitNormsCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.omit_norm"));
+    omitNormsCB.setOpaque(false);
+    idxOptCombo.setPreferredSize(new Dimension(300, idxOptCombo.getPreferredSize().height));
+    storeTVCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.store_tv"));
+    storeTVCB.setOpaque(false);
+    storeTVPosCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.store_tv_pos"));
+    storeTVPosCB.setOpaque(false);
+    storeTVOffCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.store_tv_off"));
+    storeTVOffCB.setOpaque(false);
+    storeTVPayCB.setText(MessageUtils.getLocalizedMessage("idx_options.checkbox.store_tv_pay"));
+    storeTVPayCB.setOpaque(false);
+    dimCountTF.setColumns(4);
+    dimNumBytesTF.setColumns(4);
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    panel.add(indexOptions());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(tvOptions());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(dvOptions());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(pvOptions());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(footer());
+    return panel;
+  }
+
+  private JPanel indexOptions() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel inner1 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 5));
+    inner1.setOpaque(false);
+    inner1.add(storedCB);
+
+    inner1.add(tokenizedCB);
+    inner1.add(omitNormsCB);
+    panel.add(inner1);
+
+    JPanel inner2 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 1));
+    inner2.setOpaque(false);
+    JLabel idxOptLbl = new JLabel(MessageUtils.getLocalizedMessage("idx_options.label.index_options"));
+    inner2.add(idxOptLbl);
+    inner2.add(idxOptCombo);
+    panel.add(inner2);
+
+    return panel;
+  }
+
+  private JPanel tvOptions() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel inner1 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    inner1.setOpaque(false);
+    inner1.add(storeTVCB);
+    panel.add(inner1);
+
+    JPanel inner2 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    inner2.setOpaque(false);
+    inner2.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
+    inner2.add(storeTVPosCB);
+    inner2.add(storeTVOffCB);
+    inner2.add(storeTVPayCB);
+    panel.add(inner2);
+
+    return panel;
+  }
+
+  private JPanel dvOptions() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    panel.setOpaque(false);
+    JLabel dvTypeLbl = new JLabel(MessageUtils.getLocalizedMessage("idx_options.label.dv_type"));
+    panel.add(dvTypeLbl);
+    panel.add(dvTypeCombo);
+    return panel;
+  }
+
+  private JPanel pvOptions() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel inner1 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    inner1.setOpaque(false);
+    inner1.add(new JLabel(MessageUtils.getLocalizedMessage("idx_options.label.point_dims")));
+    panel.add(inner1);
+
+    JPanel inner2 = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 2));
+    inner2.setOpaque(false);
+    inner2.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
+    inner2.add(new JLabel(MessageUtils.getLocalizedMessage("idx_options.label.point_dc")));
+    inner2.add(dimCountTF);
+    inner2.add(new JLabel(MessageUtils.getLocalizedMessage("idx_options.label.point_nb")));
+    inner2.add(dimNumBytesTF);
+    panel.add(inner2);
+
+    return panel;
+  }
+
+  private JPanel footer() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    panel.setOpaque(false);
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.setMargin(new Insets(3, 3, 3, 3));
+    okBtn.addActionListener(e -> saveOptions());
+    panel.add(okBtn);
+    JButton cancelBtn = new JButton(MessageUtils.getLocalizedMessage("button.cancel"));
+    cancelBtn.setMargin(new Insets(3, 3, 3, 3));
+    cancelBtn.addActionListener(e -> dialog.dispose());
+    panel.add(cancelBtn);
+
+    return panel;
+  }
+
+  // control methods
+
+  public void setNewField(NewField nf) {
+    this.nf = nf;
+
+    storedCB.setSelected(nf.isStored());
+
+    IndexableFieldType fieldType = nf.getFieldType();
+    tokenizedCB.setSelected(fieldType.tokenized());
+    omitNormsCB.setSelected(fieldType.omitNorms());
+    idxOptCombo.setSelectedItem(fieldType.indexOptions().name());
+    storeTVCB.setSelected(fieldType.storeTermVectors());
+    storeTVPosCB.setSelected(fieldType.storeTermVectorPositions());
+    storeTVOffCB.setSelected(fieldType.storeTermVectorOffsets());
+    storeTVPayCB.setSelected(fieldType.storeTermVectorPayloads());
+    dvTypeCombo.setSelectedItem(fieldType.docValuesType().name());
+    dimCountTF.setText(String.valueOf(fieldType.pointDataDimensionCount()));
+    dimNumBytesTF.setText(String.valueOf(fieldType.pointNumBytes()));
+
+    if (nf.getType().equals(org.apache.lucene.document.TextField.class) ||
+        nf.getType().equals(StringField.class) ||
+        nf.getType().equals(Field.class)) {
+      storedCB.setEnabled(true);
+    } else {
+      storedCB.setEnabled(false);
+    }
+
+    if (nf.getType().equals(Field.class)) {
+      tokenizedCB.setEnabled(true);
+      omitNormsCB.setEnabled(true);
+      idxOptCombo.setEnabled(true);
+      storeTVCB.setEnabled(true);
+      storeTVPosCB.setEnabled(true);
+      storeTVOffCB.setEnabled(true);
+      storeTVPosCB.setEnabled(true);
+    } else {
+      tokenizedCB.setEnabled(false);
+      omitNormsCB.setEnabled(false);
+      idxOptCombo.setEnabled(false);
+      storeTVCB.setEnabled(false);
+      storeTVPosCB.setEnabled(false);
+      storeTVOffCB.setEnabled(false);
+      storeTVPayCB.setEnabled(false);
+    }
+
+    // TODO
+    dvTypeCombo.setEnabled(false);
+    dimCountTF.setEnabled(false);
+    dimNumBytesTF.setEnabled(false);
+  }
+
+  private void saveOptions() {
+    nf.setStored(storedCB.isSelected());
+    if (nf.getType().equals(Field.class)) {
+      FieldType ftype = (FieldType) nf.getFieldType();
+      ftype.setStored(storedCB.isSelected());
+      ftype.setTokenized(tokenizedCB.isSelected());
+      ftype.setOmitNorms(omitNormsCB.isSelected());
+      ftype.setIndexOptions(IndexOptions.valueOf((String) idxOptCombo.getSelectedItem()));
+      ftype.setStoreTermVectors(storeTVCB.isSelected());
+      ftype.setStoreTermVectorPositions(storeTVPosCB.isSelected());
+      ftype.setStoreTermVectorOffsets(storeTVOffCB.isSelected());
+      ftype.setStoreTermVectorPayloads(storeTVPayCB.isSelected());
+    }
+    dialog.dispose();
+  }
+
+  private static String[] availableIndexOptions() {
+    return Arrays.stream(IndexOptions.values()).map(IndexOptions::name).toArray(String[]::new);
+  }
+
+  private static String[] availableDocValuesType() {
+    return Arrays.stream(DocValuesType.values()).map(DocValuesType::name).toArray(String[]::new);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/StoredValueDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/StoredValueDialogFactory.java
new file mode 100644
index 00000000000..bd179f7e7ad
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/StoredValueDialogFactory.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Insets;
+import java.awt.Toolkit;
+import java.awt.Window;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.io.IOException;
+import java.util.Objects;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Factory of stored values dialog */
+public final class StoredValueDialogFactory implements DialogOpener.DialogFactory {
+
+  private static StoredValueDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private String field;
+
+  private String value;
+
+  public synchronized static StoredValueDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new StoredValueDialogFactory();
+    }
+    return instance;
+  }
+
+  public void setField(String field) {
+    this.field = field;
+  }
+
+  public void setValue(String value) {
+    this.value = value;
+  }
+
+  private StoredValueDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    if (Objects.isNull(field) || Objects.isNull(value)) {
+      throw new IllegalStateException("field name and/or stored value is not set.");
+    }
+
+    dialog = new JDialog(owner, "Term Vector", Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING, 5, 5));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("documents.stored.label.stored_value")));
+    header.add(new JLabel(field));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JTextArea valueTA = new JTextArea(value);
+    valueTA.setLineWrap(true);
+    valueTA.setEditable(false);
+    valueTA.setBackground(Color.white);
+    JScrollPane scrollPane = new JScrollPane(valueTA);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 5, 5));
+    footer.setOpaque(false);
+
+    JButton copyBtn = new JButton(FontUtils.elegantIconHtml("&#xe0e6;", MessageUtils.getLocalizedMessage("button.copy")));
+    copyBtn.setMargin(new Insets(3, 3, 3, 3));
+    copyBtn.addActionListener(e -> {
+      Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+      StringSelection selection = new StringSelection(value);
+      clipboard.setContents(selection, null);
+    });
+    footer.add(copyBtn);
+
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setMargin(new Insets(3, 3, 3, 3));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/TermVectorDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/TermVectorDialogFactory.java
new file mode 100644
index 00000000000..2e7da587af4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/TermVectorDialogFactory.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.documents.TermVectorEntry;
+
+/** Factory of term vector dialog */
+public final class TermVectorDialogFactory implements DialogOpener.DialogFactory {
+
+  private static TermVectorDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private String field;
+
+  private List<TermVectorEntry> tvEntries;
+
+  public synchronized static TermVectorDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new TermVectorDialogFactory();
+    }
+    return instance;
+  }
+
+  private TermVectorDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setField(String field) {
+    this.field = field;
+  }
+
+  public void setTvEntries(List<TermVectorEntry> tvEntries) {
+    this.tvEntries = tvEntries;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    if (Objects.isNull(field) || Objects.isNull(tvEntries)) {
+      throw new IllegalStateException("field name and/or term vector is not set.");
+    }
+
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING, 5, 5));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("documents.termvector.label.term_vector")));
+    header.add(new JLabel(field));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JTable tvTable = new JTable();
+    TableUtils.setupTable(tvTable, ListSelectionModel.SINGLE_SELECTION, new TermVectorTableModel(tvEntries), null, 100, 50, 100);
+    JScrollPane scrollPane = new JScrollPane(tvTable);
+    panel.add(scrollPane, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 0, 10));
+    footer.setOpaque(false);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setMargin(new Insets(3, 3, 3, 3));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  static final class TermVectorTableModel extends TableModelBase<TermVectorTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      TERM("Term", 0, String.class),
+      FREQ("Freq", 1, Long.class),
+      POSITIONS("Positions", 2, String.class),
+      OFFSETS("Offsets", 3, String.class);
+
+      private String colName;
+      private int index;
+      private Class<?> type;
+
+      Column(String colName, int index, Class<?> type) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+    }
+
+    TermVectorTableModel() {
+      super();
+    }
+
+    TermVectorTableModel(List<TermVectorEntry> tvEntries) {
+      super(tvEntries.size());
+
+      for (int i = 0; i < tvEntries.size(); i++) {
+        TermVectorEntry entry = tvEntries.get(i);
+
+        String termText = entry.getTermText();
+        long freq = tvEntries.get(i).getFreq();
+        String positions = String.join(",",
+            entry.getPositions().stream()
+                .map(pos -> Integer.toString(pos.getPosition()))
+                .collect(Collectors.toList()));
+        String offsets = String.join(",",
+            entry.getPositions().stream()
+                .filter(pos -> pos.getStartOffset().isPresent() && pos.getEndOffset().isPresent())
+                .map(pos -> Integer.toString(pos.getStartOffset().orElse(-1)) + "-" + Integer.toString(pos.getEndOffset().orElse(-1)))
+                .collect(Collectors.toList())
+        );
+
+        data[i] = new Object[]{termText, freq, positions, offsets};
+      }
+
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/package-info.java
new file mode 100644
index 00000000000..9c641f99469
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/documents/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Dialogs used in the Documents tab */
+package org.apache.lucene.luke.app.desktop.components.dialog.documents;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/AboutDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/AboutDialogFactory.java
new file mode 100644
index 00000000000..e9d9c9731a6
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/AboutDialogFactory.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JEditorPane;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.ScrollPaneConstants;
+import javax.swing.SwingUtilities;
+import javax.swing.event.HyperlinkEvent;
+import javax.swing.event.HyperlinkListener;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Desktop;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Objects;
+
+import org.apache.lucene.LucenePackage;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ImageUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.URLLabel;
+import org.apache.lucene.luke.models.LukeException;
+
+/** Factory of about dialog */
+public final class AboutDialogFactory implements DialogOpener.DialogFactory {
+
+  private static AboutDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  public synchronized static AboutDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new AboutDialogFactory();
+    }
+    return instance;
+  }
+
+  private AboutDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+    panel.add(header(), BorderLayout.PAGE_START);
+    panel.add(center(), BorderLayout.CENTER);
+    panel.add(footer(), BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private JPanel header() {
+    JPanel panel = new JPanel(new GridLayout(3, 1));
+    panel.setOpaque(false);
+
+    JPanel logo = new JPanel(new FlowLayout(FlowLayout.CENTER));
+    logo.setOpaque(false);
+    logo.add(new JLabel(ImageUtils.createImageIcon("luke-logo.gif", 200, 40)));
+    panel.add(logo);
+
+    JPanel project = new JPanel(new FlowLayout(FlowLayout.CENTER));
+    project.setOpaque(false);
+    JLabel projectLbl = new JLabel("Lucene Toolbox Project");
+    projectLbl.setFont(new Font(projectLbl.getFont().getFontName(), Font.BOLD, 32));
+    projectLbl.setForeground(Color.decode("#5aaa88"));
+    project.add(projectLbl);
+    panel.add(project);
+
+    JPanel desc = new JPanel();
+    desc.setOpaque(false);
+    desc.setLayout(new BoxLayout(desc, BoxLayout.PAGE_AXIS));
+
+    JPanel subTitle = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 5));
+    subTitle.setOpaque(false);
+    JLabel subTitleLbl = new JLabel("GUI client of the best Java search library Apache Lucene");
+    subTitleLbl.setFont(new Font(subTitleLbl.getFont().getFontName(), Font.PLAIN, 20));
+    subTitle.add(subTitleLbl);
+    subTitle.add(new JLabel(ImageUtils.createImageIcon("lucene-logo.gif", 100, 15)));
+    desc.add(subTitle);
+
+    JPanel link = new JPanel(new FlowLayout(FlowLayout.CENTER, 5, 5));
+    link.setOpaque(false);
+    JLabel linkLbl = FontUtils.toLinkText(new URLLabel("https://lucene.apache.org/"));
+    link.add(linkLbl);
+    desc.add(link);
+
+    panel.add(desc);
+
+    return panel;
+  }
+
+  private JScrollPane center() {
+    JEditorPane editorPane = new JEditorPane();
+    editorPane.setOpaque(false);
+    editorPane.setMargin(new Insets(0, 5, 2, 5));
+    editorPane.setContentType("text/html");
+    editorPane.setText(LICENSE_NOTICE);
+    editorPane.setEditable(false);
+    editorPane.addHyperlinkListener(hyperlinkListener);
+    JScrollPane scrollPane = new JScrollPane(editorPane, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
+    scrollPane.setBorder(BorderFactory.createLineBorder(Color.gray));
+    SwingUtilities.invokeLater(() -> {
+      // Set the scroll bar position to top
+      scrollPane.getVerticalScrollBar().setValue(0);
+    });
+    return scrollPane;
+  }
+
+  private JPanel footer() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    panel.setOpaque(false);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setMargin(new Insets(5, 5, 5, 5));
+    if (closeBtn.getActionListeners().length == 0) {
+      closeBtn.addActionListener(e -> dialog.dispose());
+    }
+    panel.add(closeBtn);
+    return panel;
+  }
+
+  private static final String LUCENE_IMPLEMENTATION_VERSION = LucenePackage.get().getImplementationVersion();
+
+  private static final String LICENSE_NOTICE =
+      "<p>[Implementation Version]</p>" +
+          "<p>" + (Objects.nonNull(LUCENE_IMPLEMENTATION_VERSION) ? LUCENE_IMPLEMENTATION_VERSION : "") + "</p>" +
+          "<p>[License]</p>" +
+          "<p>Luke is distributed under <a href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache License Version 2.0</a> (http://www.apache.org/licenses/LICENSE-2.0) " +
+          "and includes <a href=\"https://www.elegantthemes.com/blog/resources/elegant-icon-font\">The Elegant Icon Font</a> (https://www.elegantthemes.com/blog/resources/elegant-icon-font) " +
+          "licensed under <a href=\"https://opensource.org/licenses/MIT\">MIT</a> (https://opensource.org/licenses/MIT)</p>" +
+          "<p>[Brief history]</p>" +
+          "<ul>" +
+          "<li>The original author is Andrzej Bialecki</li>" +
+          "<li>The project has been mavenized by Neil Ireson</li>" +
+          "<li>The project has been ported to Lucene trunk (marked as 5.0 at the time) by Dmitry Kan\n</li>" +
+          "<li>The project has been back-ported to Lucene 4.3 by sonarname</li>" +
+          "<li>There are updates to the (non-mavenized) project done by tarzanek</li>" +
+          "<li>The UI and core components has been re-implemented on top of Swing by Tomoko Uchida</li>" +
+          "</ul>"
+      ;
+
+
+  private static final HyperlinkListener hyperlinkListener = e -> {
+    if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED)
+      if (Desktop.isDesktopSupported()) {
+        try {
+          Desktop.getDesktop().browse(e.getURL().toURI());
+        } catch (IOException | URISyntaxException ex) {
+          throw new LukeException(ex.getMessage(), ex);
+        }
+      }
+  };
+
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CheckIndexDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CheckIndexDialogFactory.java
new file mode 100644
index 00000000000..3928ba699b3
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CheckIndexDialogFactory.java
@@ -0,0 +1,387 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JTextArea;
+import javax.swing.SwingWorker;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.awt.event.ActionEvent;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.invoke.MethodHandles;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.index.CheckIndex;
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.DirectoryObserver;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ImageUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TextAreaPrintStream;
+import org.apache.lucene.luke.models.tools.IndexTools;
+import org.apache.lucene.luke.models.tools.IndexToolsFactory;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.util.NamedThreadFactory;
+
+/** Factory of check index dialog */
+public final class CheckIndexDialogFactory implements DialogOpener.DialogFactory {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static CheckIndexDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final IndexToolsFactory indexToolsFactory;
+
+  private final DirectoryHandler directoryHandler;
+
+  private final IndexHandler indexHandler;
+
+  private final JLabel resultLbl = new JLabel();
+
+  private final JLabel statusLbl = new JLabel();
+
+  private final JLabel indicatorLbl = new JLabel();
+
+  private final JButton repairBtn = new JButton();
+
+  private final JTextArea logArea = new JTextArea();
+
+  private JDialog dialog;
+
+  private LukeState lukeState;
+
+  private CheckIndex.Status status;
+
+  private IndexTools toolsModel;
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  public synchronized static CheckIndexDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new CheckIndexDialogFactory();
+    }
+    return instance;
+  }
+
+  private CheckIndexDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.indexToolsFactory = new IndexToolsFactory();
+    this.indexHandler = IndexHandler.getInstance();
+    this.directoryHandler = DirectoryHandler.getInstance();
+
+    indexHandler.addObserver(new Observer());
+    directoryHandler.addObserver(new Observer());
+
+    initialize();
+  }
+
+  private void initialize() {
+    repairBtn.setText(FontUtils.elegantIconHtml("&#xe036;", MessageUtils.getLocalizedMessage("checkidx.button.fix")));
+    repairBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    repairBtn.setMargin(new Insets(3, 3, 3, 3));
+    repairBtn.setEnabled(false);
+    repairBtn.addActionListener(listeners::repairIndex);
+
+    indicatorLbl.setIcon(ImageUtils.createImageIcon("indicator.gif", 20, 20));
+
+    logArea.setEditable(false);
+  }
+
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    panel.add(controller());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(logs());
+
+    return panel;
+  }
+
+  private JPanel controller() {
+    JPanel panel = new JPanel(new GridLayout(3, 1));
+    panel.setOpaque(false);
+
+    JPanel idxPath = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    idxPath.setOpaque(false);
+    idxPath.add(new JLabel(MessageUtils.getLocalizedMessage("checkidx.label.index_path")));
+    JLabel idxPathLbl = new JLabel(lukeState.getIndexPath());
+    idxPathLbl.setToolTipText(lukeState.getIndexPath());
+    idxPath.add(idxPathLbl);
+    panel.add(idxPath);
+
+    JPanel results = new JPanel(new GridLayout(2, 1));
+    results.setOpaque(false);
+    results.setBorder(BorderFactory.createEmptyBorder(0, 5, 0, 0));
+    results.add(new JLabel(MessageUtils.getLocalizedMessage("checkidx.label.results")));
+    results.add(resultLbl);
+    panel.add(results);
+
+    JPanel execButtons = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    execButtons.setOpaque(false);
+    JButton checkBtn = new JButton(FontUtils.elegantIconHtml("&#xe0f7;", MessageUtils.getLocalizedMessage("checkidx.button.check")));
+    checkBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    checkBtn.setMargin(new Insets(3, 0, 3, 0));
+    checkBtn.addActionListener(listeners::checkIndex);
+    execButtons.add(checkBtn);
+
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    closeBtn.setMargin(new Insets(3, 0, 3, 0));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    execButtons.add(closeBtn);
+    panel.add(execButtons);
+
+    return panel;
+  }
+
+  private JPanel logs() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel header = new JPanel();
+    header.setOpaque(false);
+    header.setLayout(new BoxLayout(header, BoxLayout.PAGE_AXIS));
+
+    JPanel repair = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    repair.setOpaque(false);
+    repair.add(repairBtn);
+
+    JTextArea warnArea = new JTextArea(MessageUtils.getLocalizedMessage("checkidx.label.warn"), 3, 30);
+    warnArea.setLineWrap(true);
+    warnArea.setEditable(false);
+    warnArea.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+
+    repair.add(warnArea);
+    header.add(repair);
+
+    JPanel note = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    note.setOpaque(false);
+    note.add(new JLabel(MessageUtils.getLocalizedMessage("checkidx.label.note")));
+    header.add(note);
+
+    JPanel status = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    status.setOpaque(false);
+    status.add(new JLabel(MessageUtils.getLocalizedMessage("label.status")));
+    statusLbl.setText("Idle");
+    status.add(statusLbl);
+    indicatorLbl.setVisible(false);
+    status.add(indicatorLbl);
+    header.add(status);
+
+    panel.add(header, BorderLayout.PAGE_START);
+
+    logArea.setText("");
+    panel.add(new JScrollPane(logArea), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private class Observer implements IndexObserver, DirectoryObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      lukeState = state;
+      toolsModel = indexToolsFactory.newInstance(state.getIndexReader(), state.useCompound(), state.keepAllCommits());
+    }
+
+    @Override
+    public void closeIndex() {
+      close();
+    }
+
+    @Override
+    public void openDirectory(LukeState state) {
+      lukeState = state;
+      toolsModel = indexToolsFactory.newInstance(state.getDirectory());
+    }
+
+    @Override
+    public void closeDirectory() {
+      close();
+    }
+
+    private void close() {
+      toolsModel = null;
+    }
+  }
+
+  private class ListenerFunctions {
+
+    void checkIndex(ActionEvent e) {
+      ExecutorService executor = Executors.newFixedThreadPool(1, new NamedThreadFactory("check-index-dialog-check"));
+
+      SwingWorker<CheckIndex.Status, Void> task = new SwingWorker<CheckIndex.Status, Void>() {
+
+        @Override
+        protected CheckIndex.Status doInBackground() {
+          setProgress(0);
+          statusLbl.setText("Running...");
+          indicatorLbl.setVisible(true);
+          TextAreaPrintStream ps;
+          try {
+            ps = new TextAreaPrintStream(logArea);
+            CheckIndex.Status status = toolsModel.checkIndex(ps);
+            ps.flush();
+            return status;
+          } catch (UnsupportedEncodingException e) {
+            // will not reach
+          } catch (Exception e) {
+            statusLbl.setText(MessageUtils.getLocalizedMessage("message.error.unknown"));
+            throw e;
+          } finally {
+            setProgress(100);
+          }
+          return null;
+        }
+
+        @Override
+        protected void done() {
+          try {
+            CheckIndex.Status st = get();
+            resultLbl.setText(createResultsMessage(st));
+            indicatorLbl.setVisible(false);
+            statusLbl.setText("Done");
+            if (!st.clean) {
+              repairBtn.setEnabled(true);
+            }
+            status = st;
+          } catch (Exception e) {
+            log.error(e.getMessage(), e);
+            statusLbl.setText(MessageUtils.getLocalizedMessage("message.error.unknown"));
+          }
+        }
+      };
+
+      executor.submit(task);
+      executor.shutdown();
+    }
+
+    private String createResultsMessage(CheckIndex.Status status) {
+      String msg;
+      if (status == null) {
+        msg = "?";
+      } else if (status.clean) {
+        msg = "OK";
+      } else if (status.toolOutOfDate) {
+        msg = "ERROR: Can't check - tool out-of-date";
+      } else {
+        StringBuilder sb = new StringBuilder("BAD:");
+        if (status.missingSegments) {
+          sb.append(" Missing segments.");
+        }
+        if (status.numBadSegments > 0) {
+          sb.append(" numBadSegments=");
+          sb.append(status.numBadSegments);
+        }
+        if (status.totLoseDocCount > 0) {
+          sb.append(" totLoseDocCount=");
+          sb.append(status.totLoseDocCount);
+        }
+        msg = sb.toString();
+      }
+      return msg;
+    }
+
+    void repairIndex(ActionEvent e) {
+      if (status == null) {
+        return;
+      }
+
+      ExecutorService executor = Executors.newFixedThreadPool(1, new NamedThreadFactory("check-index-dialog-repair"));
+
+      SwingWorker<CheckIndex.Status, Void> task = new SwingWorker<CheckIndex.Status, Void>() {
+
+        @Override
+        protected CheckIndex.Status doInBackground() {
+          setProgress(0);
+          statusLbl.setText("Running...");
+          indicatorLbl.setVisible(true);
+          logArea.setText("");
+          TextAreaPrintStream ps;
+          try {
+            ps = new TextAreaPrintStream(logArea);
+            toolsModel.repairIndex(status, ps);
+            statusLbl.setText("Done");
+            ps.flush();
+            return status;
+          } catch (UnsupportedEncodingException e) {
+            // will not occur
+          } catch (Exception e) {
+            statusLbl.setText(MessageUtils.getLocalizedMessage("message.error.unknown"));
+            throw e;
+          } finally {
+            setProgress(100);
+          }
+          return null;
+        }
+
+        @Override
+        protected void done() {
+          indexHandler.open(lukeState.getIndexPath(), lukeState.getDirImpl());
+          logArea.append("Repairing index done.");
+          resultLbl.setText("");
+          indicatorLbl.setVisible(false);
+          repairBtn.setEnabled(false);
+        }
+      };
+
+      executor.submit(task);
+      executor.shutdown();
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CreateIndexDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CreateIndexDialogFactory.java
new file mode 100644
index 00000000000..03c6262af7c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/CreateIndexDialogFactory.java
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.JTextArea;
+import javax.swing.JTextField;
+import javax.swing.SwingWorker;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Window;
+import java.awt.event.ActionEvent;
+import java.io.File;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ImageUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.URLLabel;
+import org.apache.lucene.luke.models.tools.IndexTools;
+import org.apache.lucene.luke.models.tools.IndexToolsFactory;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.util.NamedThreadFactory;
+import org.apache.lucene.util.SuppressForbidden;
+
+/** Factory of create index dialog */
+public class CreateIndexDialogFactory implements DialogOpener.DialogFactory {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static CreateIndexDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final IndexHandler indexHandler;
+
+  private final JTextField locationTF = new JTextField();
+
+  private final JButton browseBtn = new JButton();
+
+  private final JTextField dirnameTF = new JTextField();
+
+  private final JTextField dataDirTF = new JTextField();
+
+  private final JButton dataBrowseBtn = new JButton();
+
+  private final JButton clearBtn = new JButton();
+
+  private final JLabel indicatorLbl = new JLabel();
+
+  private final JButton createBtn = new JButton();
+
+  private final JButton cancelBtn = new JButton();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private JDialog dialog;
+
+  public synchronized static CreateIndexDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new CreateIndexDialogFactory();
+    }
+    return instance;
+  }
+
+  private  CreateIndexDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.indexHandler = IndexHandler.getInstance();
+    initialize();
+  }
+
+  private void initialize() {
+    locationTF.setPreferredSize(new Dimension(360, 30));
+    locationTF.setText(System.getProperty("user.home"));
+    locationTF.setEditable(false);
+
+    browseBtn.setText(FontUtils.elegantIconHtml("&#x6e;", MessageUtils.getLocalizedMessage("button.browse")));
+    browseBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    browseBtn.setPreferredSize(new Dimension(120, 30));
+    browseBtn.addActionListener(listeners::browseLocationDirectory);
+
+    dirnameTF.setPreferredSize(new Dimension(200, 30));
+
+    dataDirTF.setPreferredSize(new Dimension(250, 30));
+    dataDirTF.setEditable(false);
+
+    clearBtn.setText(MessageUtils.getLocalizedMessage("button.clear"));
+    clearBtn.setPreferredSize(new Dimension(70, 30));
+    clearBtn.addActionListener(listeners::clearDataDir);
+
+    dataBrowseBtn.setText(FontUtils.elegantIconHtml("&#x6e;", MessageUtils.getLocalizedMessage("button.browse")));
+    dataBrowseBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    dataBrowseBtn.setPreferredSize(new Dimension(100, 30));
+    dataBrowseBtn.addActionListener(listeners::browseDataDirectory);
+
+    indicatorLbl.setIcon(ImageUtils.createImageIcon("indicator.gif", 20, 20));
+    indicatorLbl.setVisible(false);
+
+    createBtn.setText(MessageUtils.getLocalizedMessage("button.create"));
+    createBtn.addActionListener(listeners::createIndex);
+
+    cancelBtn.setText(MessageUtils.getLocalizedMessage("button.cancel"));
+    cancelBtn.addActionListener(e -> dialog.dispose());
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(basicSettings());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(optionalSettings());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(buttons());
+
+    return panel;
+  }
+
+  private JPanel basicSettings() {
+    JPanel panel = new JPanel(new GridLayout(2, 1));
+    panel.setOpaque(false);
+
+    JPanel locPath = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    locPath.setOpaque(false);
+    locPath.add(new JLabel(MessageUtils.getLocalizedMessage("createindex.label.location")));
+    locPath.add(locationTF);
+    locPath.add(browseBtn);
+    panel.add(locPath);
+
+    JPanel dirName = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    dirName.setOpaque(false);
+    dirName.add(new JLabel(MessageUtils.getLocalizedMessage("createindex.label.dirname")));
+    dirName.add(dirnameTF);
+    panel.add(dirName);
+
+    return panel;
+  }
+
+  private JPanel optionalSettings() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel description = new JPanel();
+    description.setLayout(new BoxLayout(description, BoxLayout.Y_AXIS));
+    description.setOpaque(false);
+
+    JPanel name = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    name.setOpaque(false);
+    JLabel nameLbl = new JLabel(MessageUtils.getLocalizedMessage("createindex.label.option"));
+    name.add(nameLbl);
+    description.add(name);
+
+    JTextArea descTA1 = new JTextArea(MessageUtils.getLocalizedMessage("createindex.textarea.data_help1"));
+    descTA1.setPreferredSize(new Dimension(550, 20));
+    descTA1.setBorder(BorderFactory.createEmptyBorder(2, 10, 10, 5));
+    descTA1.setOpaque(false);
+    descTA1.setLineWrap(true);
+    descTA1.setEditable(false);
+    description.add(descTA1);
+
+    JPanel link = new JPanel(new FlowLayout(FlowLayout.LEADING, 10, 1));
+    link.setOpaque(false);
+    JLabel linkLbl = FontUtils.toLinkText(new URLLabel(MessageUtils.getLocalizedMessage("createindex.label.data_link")));
+    link.add(linkLbl);
+    description.add(link);
+
+    JTextArea descTA2 = new JTextArea(MessageUtils.getLocalizedMessage("createindex.textarea.data_help2"));
+    descTA2.setPreferredSize(new Dimension(550, 50));
+    descTA2.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 5));
+    descTA2.setOpaque(false);
+    descTA2.setLineWrap(true);
+    descTA2.setEditable(false);
+    description.add(descTA2);
+
+    panel.add(description, BorderLayout.PAGE_START);
+
+    JPanel dataDirPath = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    dataDirPath.setOpaque(false);
+    dataDirPath.add(new JLabel(MessageUtils.getLocalizedMessage("createindex.label.datadir")));
+    dataDirPath.add(dataDirTF);
+    dataDirPath.add(dataBrowseBtn);
+
+    dataDirPath.add(clearBtn);
+    panel.add(dataDirPath, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private JPanel buttons() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 10, 20));
+
+    panel.add(indicatorLbl);
+    panel.add(createBtn);
+    panel.add(cancelBtn);
+
+    return panel;
+  }
+
+  private class ListenerFunctions {
+
+    void browseLocationDirectory(ActionEvent e) {
+      browseDirectory(locationTF);
+    }
+
+    void browseDataDirectory(ActionEvent e) {
+      browseDirectory(dataDirTF);
+    }
+
+    @SuppressForbidden(reason = "JFilechooser#getSelectedFile() returns java.io.File")
+    private void browseDirectory(JTextField tf) {
+      JFileChooser fc = new JFileChooser(new File(tf.getText()));
+      fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+      fc.setFileHidingEnabled(false);
+      int retVal = fc.showOpenDialog(dialog);
+      if (retVal == JFileChooser.APPROVE_OPTION) {
+        File dir = fc.getSelectedFile();
+        tf.setText(dir.getAbsolutePath());
+      }
+    }
+
+    void createIndex(ActionEvent e) {
+      Path path = Paths.get(locationTF.getText(), dirnameTF.getText());
+      if (Files.exists(path)) {
+        String message = "The directory " + path.toAbsolutePath().toString() + " already exists.";
+        JOptionPane.showMessageDialog(dialog, message, "Empty index path", JOptionPane.ERROR_MESSAGE);
+      } else {
+        // create new index asynchronously
+        ExecutorService executor = Executors.newFixedThreadPool(1, new NamedThreadFactory("create-index-dialog"));
+
+        SwingWorker<Void, Void> task = new SwingWorker<Void, Void>() {
+
+          @Override
+          protected Void doInBackground() throws Exception {
+            setProgress(0);
+            indicatorLbl.setVisible(true);
+            createBtn.setEnabled(false);
+
+            try {
+              Directory dir = FSDirectory.open(path);
+              IndexTools toolsModel = new IndexToolsFactory().newInstance(dir);
+
+              if (dataDirTF.getText().isEmpty()) {
+                // without sample documents
+                toolsModel.createNewIndex();
+              } else {
+                // with sample documents
+                Path dataPath = Paths.get(dataDirTF.getText());
+                toolsModel.createNewIndex(dataPath.toAbsolutePath().toString());
+              }
+
+              indexHandler.open(path.toAbsolutePath().toString(), null, false, false, false);
+              prefs.addHistory(path.toAbsolutePath().toString());
+
+              dirnameTF.setText("");
+              closeDialog();
+            } catch (Exception ex) {
+              // cleanup
+              try {
+                Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
+                  @Override
+                  public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+                    Files.delete(file);
+                    return FileVisitResult.CONTINUE;
+                  }
+                });
+                Files.deleteIfExists(path);
+              } catch (IOException ex2) {
+              }
+
+              log.error("Cannot create index", ex);
+              String message = "See Logs tab or log file for more details.";
+              JOptionPane.showMessageDialog(dialog, message, "Cannot create index", JOptionPane.ERROR_MESSAGE);
+            } finally {
+              setProgress(100);
+            }
+            return null;
+          }
+
+          @Override
+          protected void done() {
+            indicatorLbl.setVisible(false);
+            createBtn.setEnabled(true);
+          }
+        };
+
+        executor.submit(task);
+        executor.shutdown();
+      }
+    }
+
+    private void clearDataDir(ActionEvent e) {
+      dataDirTF.setText("");
+    }
+
+    private void closeDialog() {
+      dialog.dispose();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OpenIndexDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OpenIndexDialogFactory.java
new file mode 100644
index 00000000000..782827d9744
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OpenIndexDialogFactory.java
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JSeparator;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Window;
+import java.awt.event.ActionEvent;
+import java.io.File;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.app.DirectoryHandler;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.luke.util.reflection.ClassScanner;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.util.NamedThreadFactory;
+import org.apache.lucene.util.SuppressForbidden;
+
+/** Factory of open index dialog */
+public final class OpenIndexDialogFactory implements DialogOpener.DialogFactory {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static OpenIndexDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final DirectoryHandler directoryHandler;
+
+  private final IndexHandler indexHandler;
+
+  private final JComboBox<String> idxPathCombo = new JComboBox<>();
+
+  private final JButton browseBtn = new JButton();
+
+  private final JCheckBox readOnlyCB = new JCheckBox();
+
+  private final JComboBox<String> dirImplCombo = new JComboBox<>();
+
+  private final JCheckBox noReaderCB = new JCheckBox();
+
+  private final JCheckBox useCompoundCB = new JCheckBox();
+
+  private final JRadioButton keepLastCommitRB = new JRadioButton();
+
+  private final JRadioButton keepAllCommitsRB = new JRadioButton();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private JDialog dialog;
+
+  public synchronized static OpenIndexDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new OpenIndexDialogFactory();
+    }
+    return instance;
+  }
+
+  private OpenIndexDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.directoryHandler = DirectoryHandler.getInstance();
+    this.indexHandler = IndexHandler.getInstance();
+    initialize();
+  }
+
+  private void initialize() {
+    idxPathCombo.setPreferredSize(new Dimension(360, 40));
+
+    browseBtn.setText(FontUtils.elegantIconHtml("&#x6e;", MessageUtils.getLocalizedMessage("button.browse")));
+    browseBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    browseBtn.setPreferredSize(new Dimension(120, 40));
+    browseBtn.addActionListener(listeners::browseDirectory);
+
+    readOnlyCB.setText(MessageUtils.getLocalizedMessage("openindex.checkbox.readonly"));
+    readOnlyCB.setSelected(prefs.isReadOnly());
+    readOnlyCB.addActionListener(listeners::toggleReadOnly);
+    readOnlyCB.setOpaque(false);
+
+    // Scanning all Directory types will take time...
+    ExecutorService executorService = Executors.newFixedThreadPool(1, new NamedThreadFactory("load-directory-types"));
+    executorService.execute(() -> {
+      for (String clazzName : supportedDirImpls()) {
+        dirImplCombo.addItem(clazzName);
+      }
+    });
+    executorService.shutdown();
+    dirImplCombo.setPreferredSize(new Dimension(350, 30));
+    dirImplCombo.setSelectedItem(prefs.getDirImpl());
+
+    noReaderCB.setText(MessageUtils.getLocalizedMessage("openindex.checkbox.no_reader"));
+    noReaderCB.setSelected(prefs.isNoReader());
+    noReaderCB.setOpaque(false);
+
+    useCompoundCB.setText(MessageUtils.getLocalizedMessage("openindex.checkbox.use_compound"));
+    useCompoundCB.setSelected(prefs.isUseCompound());
+    useCompoundCB.setOpaque(false);
+
+    keepLastCommitRB.setText(MessageUtils.getLocalizedMessage("openindex.radio.keep_only_last_commit"));
+    keepLastCommitRB.setSelected(!prefs.isKeepAllCommits());
+    keepLastCommitRB.setOpaque(false);
+
+    keepAllCommitsRB.setText(MessageUtils.getLocalizedMessage("openindex.radio.keep_all_commits"));
+    keepAllCommitsRB.setSelected(prefs.isKeepAllCommits());
+    keepAllCommitsRB.setOpaque(false);
+
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(basicSettings());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(expertSettings());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(buttons());
+
+    return panel;
+  }
+
+  private JPanel basicSettings() {
+    JPanel panel = new JPanel(new GridLayout(2, 1));
+    panel.setOpaque(false);
+
+    JPanel idxPath = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    idxPath.setOpaque(false);
+    idxPath.add(new JLabel(MessageUtils.getLocalizedMessage("openindex.label.index_path")));
+
+    idxPathCombo.removeAllItems();
+    for (String path : prefs.getHistory()) {
+      idxPathCombo.addItem(path);
+    }
+    idxPath.add(idxPathCombo);
+
+    idxPath.add(browseBtn);
+
+    panel.add(idxPath);
+
+    JPanel readOnly = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    readOnly.setOpaque(false);
+    readOnly.add(readOnlyCB);
+    JLabel roIconLB = new JLabel(FontUtils.elegantIconHtml("&#xe06c;"));
+    readOnly.add(roIconLB);
+    panel.add(readOnly);
+
+    return panel;
+  }
+
+  private JPanel expertSettings() {
+    JPanel panel = new JPanel(new GridLayout(6, 1));
+    panel.setOpaque(false);
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("openindex.label.expert")));
+    panel.add(header);
+
+    JPanel dirImpl = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    dirImpl.setOpaque(false);
+    dirImpl.add(new JLabel(MessageUtils.getLocalizedMessage("openindex.label.dir_impl")));
+    dirImpl.add(dirImplCombo);
+    panel.add(dirImpl);
+
+    JPanel noReader = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    noReader.setOpaque(false);
+    noReader.add(noReaderCB);
+    JLabel noReaderIcon = new JLabel(FontUtils.elegantIconHtml("&#xe077;"));
+    noReader.add(noReaderIcon);
+    panel.add(noReader);
+
+    JPanel iwConfig = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    iwConfig.setOpaque(false);
+    iwConfig.add(new JLabel(MessageUtils.getLocalizedMessage("openindex.label.iw_config")));
+    panel.add(iwConfig);
+
+    JPanel compound = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    compound.setOpaque(false);
+    compound.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    compound.add(useCompoundCB);
+    panel.add(compound);
+
+    JPanel keepCommits = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    keepCommits.setOpaque(false);
+    keepCommits.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    keepCommits.add(keepLastCommitRB);
+    keepCommits.add(keepAllCommitsRB);
+
+    ButtonGroup group = new ButtonGroup();
+    group.add(keepLastCommitRB);
+    group.add(keepAllCommitsRB);
+
+    panel.add(keepCommits);
+
+    return panel;
+  }
+
+  private String[] supportedDirImpls() {
+    // supports FS-based built-in implementations
+    ClassScanner scanner = new ClassScanner("org.apache.lucene.store", getClass().getClassLoader());
+    Set<Class<? extends FSDirectory>> clazzSet = scanner.scanSubTypes(FSDirectory.class);
+
+    List<String> clazzNames = new ArrayList<>();
+    clazzNames.add(FSDirectory.class.getName());
+    clazzNames.addAll(clazzSet.stream().map(Class::getName).collect(Collectors.toList()));
+
+    String[] result = new String[clazzNames.size()];
+    return clazzNames.toArray(result);
+  }
+
+  private JPanel buttons() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 10, 20));
+
+    JButton okBtn = new JButton(MessageUtils.getLocalizedMessage("button.ok"));
+    okBtn.addActionListener(listeners::openIndexOrDirectory);
+    panel.add(okBtn);
+
+    JButton cancelBtn = new JButton(MessageUtils.getLocalizedMessage("button.cancel"));
+    cancelBtn.addActionListener(e -> dialog.dispose());
+    panel.add(cancelBtn);
+
+    return panel;
+  }
+
+  private class ListenerFunctions {
+
+    @SuppressForbidden(reason = "FileChooser#getSelectedFile() returns java.io.File")
+    void browseDirectory(ActionEvent e) {
+      File currentDir = getLastOpenedDirectory();
+      JFileChooser fc = currentDir == null ? new JFileChooser() : new JFileChooser(currentDir);
+      fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+      fc.setFileHidingEnabled(false);
+      int retVal = fc.showOpenDialog(dialog);
+      if (retVal == JFileChooser.APPROVE_OPTION) {
+        File dir = fc.getSelectedFile();
+        idxPathCombo.insertItemAt(dir.getAbsolutePath(), 0);
+        idxPathCombo.setSelectedIndex(0);
+      }
+    }
+
+    @SuppressForbidden(reason = "JFileChooser constructor takes java.io.File")
+    private File getLastOpenedDirectory() {
+      List<String> history = prefs.getHistory();
+      if (!history.isEmpty()) {
+        Path path = Paths.get(history.get(0));
+        if (Files.exists(path)) {
+          return path.getParent().toAbsolutePath().toFile();
+        }
+      }
+      return null;
+    }
+
+    void toggleReadOnly(ActionEvent e) {
+      setWriterConfigEnabled(!isReadOnly());
+    }
+
+    private void setWriterConfigEnabled(boolean enable) {
+      useCompoundCB.setEnabled(enable);
+      keepLastCommitRB.setEnabled(enable);
+      keepAllCommitsRB.setEnabled(enable);
+    }
+
+    void openIndexOrDirectory(ActionEvent e) {
+      try {
+        if (directoryHandler.directoryOpened()) {
+          directoryHandler.close();
+        }
+        if (indexHandler.indexOpened()) {
+          indexHandler.close();
+        }
+
+        String selectedPath = (String) idxPathCombo.getSelectedItem();
+        String dirImplClazz = (String) dirImplCombo.getSelectedItem();
+        if (selectedPath == null || selectedPath.length() == 0) {
+          String message = MessageUtils.getLocalizedMessage("openindex.message.index_path_not_selected");
+          JOptionPane.showMessageDialog(dialog, message, "Empty index path", JOptionPane.ERROR_MESSAGE);
+        } else if (isNoReader()) {
+          directoryHandler.open(selectedPath, dirImplClazz);
+          addHistory(selectedPath);
+        } else {
+          indexHandler.open(selectedPath, dirImplClazz, isReadOnly(), useCompound(), keepAllCommits());
+          addHistory(selectedPath);
+        }
+        prefs.setIndexOpenerPrefs(
+            isReadOnly(), dirImplClazz,
+            isNoReader(), useCompound(), keepAllCommits());
+        closeDialog();
+      } catch (LukeException ex) {
+        String message = ex.getMessage() + System.lineSeparator() + "See Logs tab or log file for more details.";
+        JOptionPane.showMessageDialog(dialog, message, "Invalid index path", JOptionPane.ERROR_MESSAGE);
+      } catch (Throwable cause) {
+        JOptionPane.showMessageDialog(dialog, MessageUtils.getLocalizedMessage("message.error.unknown"), "Unknown Error", JOptionPane.ERROR_MESSAGE);
+        log.error(cause.getMessage(), cause);
+      }
+    }
+
+    private boolean isNoReader() {
+      return noReaderCB.isSelected();
+    }
+
+    private boolean isReadOnly() {
+      return readOnlyCB.isSelected();
+    }
+
+    private boolean useCompound() {
+      return useCompoundCB.isSelected();
+    }
+
+    private boolean keepAllCommits() {
+      return keepAllCommitsRB.isSelected();
+    }
+
+    private void closeDialog() {
+      dialog.dispose();
+    }
+
+    private void addHistory(String indexPath) throws IOException {
+      prefs.addHistory(indexPath);
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OptimizeIndexDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OptimizeIndexDialogFactory.java
new file mode 100644
index 00000000000..e5543d86856
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/OptimizeIndexDialogFactory.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JSpinner;
+import javax.swing.JTextArea;
+import javax.swing.SpinnerNumberModel;
+import javax.swing.SwingWorker;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Window;
+import java.awt.event.ActionEvent;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.lang.invoke.MethodHandles;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.app.IndexHandler;
+import org.apache.lucene.luke.app.IndexObserver;
+import org.apache.lucene.luke.app.LukeState;
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ImageUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.TextAreaPrintStream;
+import org.apache.lucene.luke.models.tools.IndexTools;
+import org.apache.lucene.luke.models.tools.IndexToolsFactory;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.util.NamedThreadFactory;
+
+/** Factory of optimize index dialog */
+public final class OptimizeIndexDialogFactory implements DialogOpener.DialogFactory {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static OptimizeIndexDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private final IndexToolsFactory indexToolsFactory = new IndexToolsFactory();
+
+  private final IndexHandler indexHandler;
+
+  private final JCheckBox expungeCB = new JCheckBox();
+
+  private final JSpinner maxSegSpnr = new JSpinner();
+
+  private final JLabel statusLbl = new JLabel();
+
+  private final JLabel indicatorLbl = new JLabel();
+
+  private final JTextArea logArea = new JTextArea();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private JDialog dialog;
+
+  private IndexTools toolsModel;
+
+  public synchronized static OptimizeIndexDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new OptimizeIndexDialogFactory();
+    }
+    return instance;
+  }
+
+  private OptimizeIndexDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+    this.indexHandler = IndexHandler.getInstance();
+    indexHandler.addObserver(new Observer());
+
+    initialize();
+  }
+
+  private void initialize() {
+    expungeCB.setText(MessageUtils.getLocalizedMessage("optimize.checkbox.expunge"));
+    expungeCB.setOpaque(false);
+
+    maxSegSpnr.setModel(new SpinnerNumberModel(1, 1, 100, 1));
+    maxSegSpnr.setPreferredSize(new Dimension(100, 30));
+
+    indicatorLbl.setIcon(ImageUtils.createImageIcon("indicator.gif", 20, 20));
+
+    logArea.setEditable(false);
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    panel.add(controller());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(logs());
+
+    return panel;
+  }
+
+  private JPanel controller() {
+    JPanel panel = new JPanel(new GridLayout(4, 1));
+    panel.setOpaque(false);
+
+    JPanel idxPath = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    idxPath.setOpaque(false);
+    idxPath.add(new JLabel(MessageUtils.getLocalizedMessage("optimize.label.index_path")));
+    JLabel idxPathLbl = new JLabel(indexHandler.getState().getIndexPath());
+    idxPathLbl.setToolTipText(indexHandler.getState().getIndexPath());
+    idxPath.add(idxPathLbl);
+    panel.add(idxPath);
+
+    JPanel expunge = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    expunge.setOpaque(false);
+
+    expunge.add(expungeCB);
+    panel.add(expunge);
+
+    JPanel maxSegs = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    maxSegs.setOpaque(false);
+    maxSegs.add(new JLabel(MessageUtils.getLocalizedMessage("optimize.label.max_segments")));
+    maxSegs.add(maxSegSpnr);
+    panel.add(maxSegs);
+
+    JPanel execButtons = new JPanel(new FlowLayout(FlowLayout.TRAILING));
+    execButtons.setOpaque(false);
+    JButton optimizeBtn = new JButton(FontUtils.elegantIconHtml("&#xe0ff;", MessageUtils.getLocalizedMessage("optimize.button.optimize")));
+    optimizeBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    optimizeBtn.setMargin(new Insets(3, 0, 3, 0));
+    optimizeBtn.addActionListener(listeners::optimize);
+    execButtons.add(optimizeBtn);
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    closeBtn.setMargin(new Insets(3, 0, 3, 0));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    execButtons.add(closeBtn);
+    panel.add(execButtons);
+
+    return panel;
+  }
+
+  private JPanel logs() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel header = new JPanel(new GridLayout(2, 1));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("optimize.label.note")));
+    JPanel status = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    status.setOpaque(false);
+    status.add(new JLabel(MessageUtils.getLocalizedMessage("label.status")));
+    statusLbl.setText("Idle");
+    status.add(statusLbl);
+    indicatorLbl.setVisible(false);
+    status.add(indicatorLbl);
+    header.add(status);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    logArea.setText("");
+    panel.add(new JScrollPane(logArea), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  private class ListenerFunctions {
+
+    void optimize(ActionEvent e) {
+      ExecutorService executor = Executors.newFixedThreadPool(1, new NamedThreadFactory("optimize-index-dialog"));
+
+      SwingWorker<Void, Void> task = new SwingWorker<Void, Void>() {
+
+        @Override
+        protected Void doInBackground() {
+          setProgress(0);
+          statusLbl.setText("Running...");
+          indicatorLbl.setVisible(true);
+          TextAreaPrintStream ps;
+          try {
+            ps = new TextAreaPrintStream(logArea);
+            toolsModel.optimize(expungeCB.isSelected(), (int) maxSegSpnr.getValue(), ps);
+            ps.flush();
+          } catch (UnsupportedEncodingException e) {
+            // will not reach
+          } catch (Exception e) {
+            statusLbl.setText(MessageUtils.getLocalizedMessage("message.error.unknown"));
+            throw e;
+          } finally {
+            setProgress(100);
+          }
+          return null;
+        }
+
+        @Override
+        protected void done() {
+          indicatorLbl.setVisible(false);
+          statusLbl.setText("Done");
+          indexHandler.reOpen();
+        }
+      };
+
+      executor.submit(task);
+      executor.shutdown();
+    }
+
+  }
+
+  private class Observer implements IndexObserver {
+
+    @Override
+    public void openIndex(LukeState state) {
+      toolsModel = indexToolsFactory.newInstance(state.getIndexReader(), state.useCompound(), state.keepAllCommits());
+    }
+
+    @Override
+    public void closeIndex() {
+      toolsModel = null;
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/package-info.java
new file mode 100644
index 00000000000..72a2d3fc7d5
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/menubar/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Dialogs used in the menu bar */
+package org.apache.lucene.luke.app.desktop.components.dialog.menubar;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/package-info.java
new file mode 100644
index 00000000000..44ad40b04fd
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Dialogs */
+package org.apache.lucene.luke.app.desktop.components.dialog;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/ExplainDialogFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/ExplainDialogFactory.java
new file mode 100644
index 00000000000..66d558d2866
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/ExplainDialogFactory.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.dialog.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTree;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.Toolkit;
+import java.awt.Window;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.io.IOException;
+import java.util.Objects;
+import java.util.stream.IntStream;
+
+import org.apache.lucene.luke.app.desktop.Preferences;
+import org.apache.lucene.luke.app.desktop.PreferencesFactory;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.search.Explanation;
+
+/** Factory of explain dialog */
+public final class ExplainDialogFactory implements DialogOpener.DialogFactory {
+
+  private static ExplainDialogFactory instance;
+
+  private final Preferences prefs;
+
+  private JDialog dialog;
+
+  private int docid = -1;
+
+  private Explanation explanation;
+
+  public synchronized static ExplainDialogFactory getInstance() throws IOException {
+    if (instance == null) {
+      instance = new ExplainDialogFactory();
+    }
+    return  instance;
+  }
+
+  private ExplainDialogFactory() throws IOException {
+    this.prefs = PreferencesFactory.getInstance();
+  }
+
+  public void setDocid(int docid) {
+    this.docid = docid;
+  }
+
+  public void setExplanation(Explanation explanation) {
+    this.explanation = explanation;
+  }
+
+  @Override
+  public JDialog create(Window owner, String title, int width, int height) {
+    if (docid < 0 || Objects.isNull(explanation)) {
+      throw new IllegalStateException("docid and/or explanation is not set.");
+    }
+
+    dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL);
+    dialog.add(content());
+    dialog.setSize(new Dimension(width, height));
+    dialog.setLocationRelativeTo(owner);
+    dialog.getContentPane().setBackground(prefs.getColorTheme().getBackgroundColor());
+    return dialog;
+  }
+
+  private JPanel content() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING, 5, 10));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search.explanation.description")));
+    header.add(new JLabel(String.valueOf(docid)));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new GridLayout(1, 1));
+    center.setOpaque(false);
+    center.add(new JScrollPane(createExplanationTree()));
+    panel.add(center, BorderLayout.CENTER);
+
+    JPanel footer = new JPanel(new FlowLayout(FlowLayout.TRAILING, 5, 5));
+    footer.setOpaque(false);
+
+    JButton copyBtn = new JButton(FontUtils.elegantIconHtml("&#xe0e6;", MessageUtils.getLocalizedMessage("button.copy")));
+    copyBtn.setMargin(new Insets(3, 3, 3, 3));
+    copyBtn.addActionListener(e -> {
+      Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+      StringSelection selection = new StringSelection(explanationToString());
+      clipboard.setContents(selection, null);
+    });
+    footer.add(copyBtn);
+
+    JButton closeBtn = new JButton(MessageUtils.getLocalizedMessage("button.close"));
+    closeBtn.setMargin(new Insets(3, 3, 3, 3));
+    closeBtn.addActionListener(e -> dialog.dispose());
+    footer.add(closeBtn);
+    panel.add(footer, BorderLayout.PAGE_END);
+
+    return panel;
+  }
+
+  private JTree createExplanationTree() {
+    DefaultMutableTreeNode top = createNode(explanation);
+    traverse(top, explanation.getDetails());
+
+    JTree tree = new JTree(top);
+    tree.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+    DefaultTreeCellRenderer renderer = new DefaultTreeCellRenderer();
+    renderer.setOpenIcon(null);
+    renderer.setClosedIcon(null);
+    renderer.setLeafIcon(null);
+    tree.setCellRenderer(renderer);
+    // expand all nodes
+    for (int row = 0; row < tree.getRowCount(); row++) {
+      tree.expandRow(row);
+    }
+    return tree;
+  }
+
+  private void traverse(DefaultMutableTreeNode parent, Explanation[] explanations) {
+    for (Explanation explanation : explanations) {
+      DefaultMutableTreeNode node = createNode(explanation);
+      parent.add(node);
+      traverse(node, explanation.getDetails());
+    }
+  }
+
+  private DefaultMutableTreeNode createNode(Explanation explanation) {
+    return new DefaultMutableTreeNode(format(explanation));
+  }
+
+  private String explanationToString() {
+    StringBuilder sb = new StringBuilder(format(explanation));
+    sb.append(System.lineSeparator());
+    traverseToCopy(sb, 1, explanation.getDetails());
+    return sb.toString();
+  }
+
+  private void traverseToCopy(StringBuilder sb, int depth, Explanation[] explanations) {
+    for (Explanation explanation : explanations) {
+      IntStream.range(0, depth).forEach(i -> sb.append("  "));
+      sb.append(format(explanation));
+      sb.append("\n");
+      traverseToCopy(sb, depth + 1, explanation.getDetails());
+    }
+  }
+
+  private String format(Explanation explanation) {
+    return explanation.getValue() + " " + explanation.getDescription();
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/package-info.java
new file mode 100644
index 00000000000..7af5fb1f80b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/dialog/search/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Dialogs used in the Search tab */
+package org.apache.lucene.luke.app.desktop.components.dialog.search;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelOperator.java
new file mode 100644
index 00000000000..54451beaae2
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelOperator.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.analysis;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.models.analysis.Analysis;
+
+/** Operator of the custom analyzer panel */
+public interface CustomAnalyzerPanelOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setAnalysisModel(Analysis analysisModel);
+
+  void resetAnalysisComponents();
+
+  void updateCharFilters(List<Integer> deletedIndexes);
+
+  void updateTokenFilters(List<Integer> deletedIndexes);
+
+  Map<String, String> getCharFilterParams(int index);
+
+  void updateCharFilterParams(int index, Map<String, String> updatedParams);
+
+  void updateTokenizerParams(Map<String, String> updatedParams);
+
+  Map<String, String> getTokenFilterParams(int index);
+
+  void updateTokenFilterParams(int index, Map<String, String> updatedParams);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelProvider.java
new file mode 100644
index 00000000000..4b1bc22fcf8
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/CustomAnalyzerPanelProvider.java
@@ -0,0 +1,751 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.DefaultComboBoxModel;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JTextField;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.GridLayout;
+import java.awt.Insets;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.app.desktop.components.AnalysisTabOperator;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.EditFiltersDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.EditFiltersMode;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.EditParamsDialogFactory;
+import org.apache.lucene.luke.app.desktop.components.dialog.analysis.EditParamsMode;
+import org.apache.lucene.luke.app.desktop.util.DialogOpener;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.ListUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.app.desktop.util.lang.Callable;
+import org.apache.lucene.luke.models.analysis.Analysis;
+import org.apache.lucene.luke.models.analysis.CustomAnalyzerConfig;
+import org.apache.lucene.util.SuppressForbidden;
+
+/** Provider of the custom analyzer panel */
+public final class CustomAnalyzerPanelProvider implements CustomAnalyzerPanelOperator {
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final EditParamsDialogFactory editParamsDialogFactory;
+
+  private final EditFiltersDialogFactory editFiltersDialogFactory;
+
+  private final MessageBroker messageBroker;
+
+  private final JTextField confDirTF = new JTextField();
+
+  private final JFileChooser fileChooser = new JFileChooser();
+
+  private final JButton confDirBtn = new JButton();
+
+  private final JButton buildBtn = new JButton();
+
+  private final JLabel loadJarLbl = new JLabel();
+
+  private final JList<String> selectedCfList = new JList<>(new String[]{});
+
+  private final JButton cfEditBtn = new JButton();
+
+  private final JComboBox<String> cfFactoryCombo = new JComboBox<>();
+
+  private final JTextField selectedTokTF = new JTextField();
+
+  private final JButton tokEditBtn = new JButton();
+
+  private final JComboBox<String> tokFactoryCombo = new JComboBox<>();
+
+  private final JList<String> selectedTfList = new JList<>(new String[]{});
+
+  private final JButton tfEditBtn = new JButton();
+
+  private final JComboBox<String> tfFactoryCombo = new JComboBox<>();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private final List<Map<String, String>> cfParamsList = new ArrayList<>();
+
+  private final Map<String, String> tokParams = new HashMap<>();
+
+  private final List<Map<String, String>> tfParamsList = new ArrayList<>();
+
+  private JPanel containerPanel;
+
+  private Analysis analysisModel;
+
+  public CustomAnalyzerPanelProvider() throws IOException {
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    this.editParamsDialogFactory = EditParamsDialogFactory.getInstance();
+    this.editFiltersDialogFactory = EditFiltersDialogFactory.getInstance();
+    this.messageBroker = MessageBroker.getInstance();
+
+    operatorRegistry.register(CustomAnalyzerPanelOperator.class, this);
+
+    cfFactoryCombo.addActionListener(listeners::addCharFilter);
+    tokFactoryCombo.addActionListener(listeners::setTokenizer);
+    tfFactoryCombo.addActionListener(listeners::addTokenFilter);
+  }
+
+  public JPanel get() {
+    if (containerPanel == null) {
+      containerPanel = new JPanel();
+      containerPanel.setOpaque(false);
+      containerPanel.setLayout(new BorderLayout());
+      containerPanel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+      containerPanel.add(initCustomAnalyzerHeaderPanel(), BorderLayout.PAGE_START);
+      containerPanel.add(initCustomAnalyzerChainPanel(), BorderLayout.CENTER);
+    }
+
+    return containerPanel;
+  }
+
+  private JPanel initCustomAnalyzerHeaderPanel() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    panel.setOpaque(false);
+
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis.label.config_dir")));
+    confDirTF.setColumns(30);
+    confDirTF.setPreferredSize(new Dimension(200, 30));
+    panel.add(confDirTF);
+    confDirBtn.setText(FontUtils.elegantIconHtml("&#x6e;", MessageUtils.getLocalizedMessage("analysis.button.browse")));
+    confDirBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    confDirBtn.setMargin(new Insets(3, 3, 3, 3));
+    confDirBtn.addActionListener(listeners::chooseConfigDir);
+    panel.add(confDirBtn);
+    buildBtn.setText(FontUtils.elegantIconHtml("&#xe102;", MessageUtils.getLocalizedMessage("analysis.button.build_analyzser")));
+    buildBtn.setFont(StyleConstants.FONT_BUTTON_LARGE);
+    buildBtn.setMargin(new Insets(3, 3, 3, 3));
+    buildBtn.addActionListener(listeners::buildAnalyzer);
+    panel.add(buildBtn);
+    loadJarLbl.setText(MessageUtils.getLocalizedMessage("analysis.hyperlink.load_jars"));
+    loadJarLbl.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        listeners.loadExternalJars(e);
+      }
+    });
+    panel.add(FontUtils.toLinkText(loadJarLbl));
+
+    return panel;
+  }
+
+  private JPanel initCustomAnalyzerChainPanel() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    panel.add(initCustomChainConfigPanel());
+
+    return panel;
+  }
+
+  private JPanel initCustomChainConfigPanel() {
+    JPanel panel = new JPanel(new GridBagLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createLineBorder(Color.black));
+
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.HORIZONTAL;
+
+    GridBagConstraints sepc = new GridBagConstraints();
+    sepc.fill = GridBagConstraints.HORIZONTAL;
+    sepc.weightx = 1.0;
+    sepc.gridwidth = GridBagConstraints.REMAINDER;
+
+    // char filters
+    JLabel cfLbl = new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.charfilters"));
+    cfLbl.setBorder(BorderFactory.createEmptyBorder(3, 10, 3, 3));
+    c.gridx = 0;
+    c.gridy = 0;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(cfLbl, c);
+
+    c.gridx = 1;
+    c.gridy = 0;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.selected")), c);
+
+    selectedCfList.setVisibleRowCount(1);
+    selectedCfList.setFont(new Font(selectedCfList.getFont().getFontName(), Font.PLAIN, 15));
+    JScrollPane selectedPanel = new JScrollPane(selectedCfList);
+    c.gridx = 2;
+    c.gridy = 0;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(selectedPanel, c);
+
+    cfEditBtn.setText(FontUtils.elegantIconHtml("&#x6a;", MessageUtils.getLocalizedMessage("analysis_custom.label.edit")));
+    cfEditBtn.setMargin(new Insets(2, 4, 2, 4));
+    cfEditBtn.setEnabled(false);
+    cfEditBtn.addActionListener(listeners::editCharFilters);
+    c.fill = GridBagConstraints.NONE;
+    c.gridx = 7;
+    c.gridy = 0;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(cfEditBtn, c);
+
+    JLabel cfAddLabel = new JLabel(FontUtils.elegantIconHtml("&#x4c;", MessageUtils.getLocalizedMessage("analysis_custom.label.add")));
+    cfAddLabel.setHorizontalAlignment(JLabel.LEFT);
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.gridx = 1;
+    c.gridy = 2;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(cfAddLabel, c);
+
+    c.gridx = 2;
+    c.gridy = 2;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(cfFactoryCombo, c);
+
+    // separator
+    sepc.gridx = 0;
+    sepc.gridy = 3;
+    sepc.anchor = GridBagConstraints.LINE_START;
+    panel.add(new JSeparator(JSeparator.HORIZONTAL), sepc);
+
+    // tokenizer
+    JLabel tokLabel = new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.tokenizer"));
+    tokLabel.setBorder(BorderFactory.createEmptyBorder(3, 10, 3, 3));
+    c.gridx = 0;
+    c.gridy = 4;
+    c.gridwidth = 1;
+    c.gridheight = 2;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(tokLabel, c);
+
+    c.gridx = 1;
+    c.gridy = 4;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.selected")), c);
+
+    selectedTokTF.setColumns(15);
+    selectedTokTF.setFont(new Font(selectedTokTF.getFont().getFontName(), Font.PLAIN, 15));
+    selectedTokTF.setBorder(BorderFactory.createLineBorder(Color.gray));
+    selectedTokTF.setText("standard");
+    selectedTokTF.setEditable(false);
+    c.gridx = 2;
+    c.gridy = 4;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(selectedTokTF, c);
+
+    tokEditBtn.setText(FontUtils.elegantIconHtml("&#x6a;", MessageUtils.getLocalizedMessage("analysis_custom.label.edit")));
+    tokEditBtn.setMargin(new Insets(2, 4, 2, 4));
+    tokEditBtn.addActionListener(listeners::editTokenizer);
+    c.fill = GridBagConstraints.NONE;
+    c.gridx = 7;
+    c.gridy = 4;
+    c.gridwidth = 2;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(tokEditBtn, c);
+
+    JLabel setTokLabel = new JLabel(FontUtils.elegantIconHtml("&#xe01e;", MessageUtils.getLocalizedMessage("analysis_custom.label.set")));
+    setTokLabel.setHorizontalAlignment(JLabel.LEFT);
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.gridx = 1;
+    c.gridy = 6;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(setTokLabel, c);
+
+    c.gridx = 2;
+    c.gridy = 6;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(tokFactoryCombo, c);
+
+    // separator
+    sepc.gridx = 0;
+    sepc.gridy = 7;
+    sepc.anchor = GridBagConstraints.LINE_START;
+    panel.add(new JSeparator(JSeparator.HORIZONTAL), sepc);
+
+    // token filters
+    JLabel tfLbl = new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.tokenfilters"));
+    tfLbl.setBorder(BorderFactory.createEmptyBorder(3, 10, 3, 3));
+    c.gridx = 0;
+    c.gridy = 8;
+    c.gridwidth = 1;
+    c.gridheight = 2;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(tfLbl, c);
+
+    c.gridx = 1;
+    c.gridy = 8;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("analysis_custom.label.selected")), c);
+
+    selectedTfList.setVisibleRowCount(1);
+    selectedTfList.setFont(new Font(selectedTfList.getFont().getFontName(), Font.PLAIN, 15));
+    JScrollPane selectedTfPanel = new JScrollPane(selectedTfList);
+    c.gridx = 2;
+    c.gridy = 8;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(selectedTfPanel, c);
+
+    tfEditBtn.setText(FontUtils.elegantIconHtml("&#x6a;", MessageUtils.getLocalizedMessage("analysis_custom.label.edit")));
+    tfEditBtn.setMargin(new Insets(2, 4, 2, 4));
+    tfEditBtn.setEnabled(false);
+    tfEditBtn.addActionListener(listeners::editTokenFilters);
+    c.fill = GridBagConstraints.NONE;
+    c.gridx = 7;
+    c.gridy = 8;
+    c.gridwidth = 2;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.CENTER;
+    panel.add(tfEditBtn, c);
+
+    JLabel tfAddLabel = new JLabel(FontUtils.elegantIconHtml("&#x4c;", MessageUtils.getLocalizedMessage("analysis_custom.label.add")));
+    tfAddLabel.setHorizontalAlignment(JLabel.LEFT);
+    c.fill = GridBagConstraints.HORIZONTAL;
+    c.gridx = 1;
+    c.gridy = 10;
+    c.gridwidth = 1;
+    c.gridheight = 1;
+    c.weightx = 0.1;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(tfAddLabel, c);
+
+    c.gridx = 2;
+    c.gridy = 10;
+    c.gridwidth = 5;
+    c.gridheight = 1;
+    c.weightx = 0.5;
+    c.weighty = 0.5;
+    c.anchor = GridBagConstraints.LINE_END;
+    panel.add(tfFactoryCombo, c);
+
+    return panel;
+  }
+
+  // control methods
+
+  @SuppressForbidden(reason = "JFilechooser#getSelectedFile() returns java.io.File")
+  private void chooseConfigDir() {
+    fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+
+    int ret = fileChooser.showOpenDialog(containerPanel);
+    if (ret == JFileChooser.APPROVE_OPTION) {
+      File dir = fileChooser.getSelectedFile();
+      confDirTF.setText(dir.getAbsolutePath());
+    }
+  }
+
+  @SuppressForbidden(reason = "JFilechooser#getSelectedFiles() returns java.io.File[]")
+  private void loadExternalJars() {
+    fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
+    fileChooser.setMultiSelectionEnabled(true);
+
+    int ret = fileChooser.showOpenDialog(containerPanel);
+    if (ret == JFileChooser.APPROVE_OPTION) {
+      File[] files = fileChooser.getSelectedFiles();
+      analysisModel.addExternalJars(Arrays.stream(files).map(File::getAbsolutePath).collect(Collectors.toList()));
+      operatorRegistry.get(CustomAnalyzerPanelOperator.class).ifPresent(operator ->
+          operator.resetAnalysisComponents()
+      );
+      messageBroker.showStatusMessage("External jars were added.");
+    }
+  }
+
+
+  private void buildAnalyzer() {
+    List<String> charFilters = ListUtils.getAllItems(selectedCfList);
+    assert charFilters.size() == cfParamsList.size();
+
+    List<String> tokenFilters = ListUtils.getAllItems(selectedTfList);
+    assert tokenFilters.size() == tfParamsList.size();
+
+    String tokenizerName = selectedTokTF.getText();
+    CustomAnalyzerConfig.Builder builder =
+        new CustomAnalyzerConfig.Builder(tokenizerName, tokParams).configDir(confDirTF.getText());
+    IntStream.range(0, charFilters.size()).forEach(i ->
+        builder.addCharFilterConfig(charFilters.get(i), cfParamsList.get(i))
+    );
+    IntStream.range(0, tokenFilters.size()).forEach(i ->
+        builder.addTokenFilterConfig(tokenFilters.get(i), tfParamsList.get(i))
+    );
+    CustomAnalyzerConfig config = builder.build();
+
+    operatorRegistry.get(AnalysisTabOperator.class).ifPresent(operator -> {
+      operator.setAnalyzerByCustomConfiguration(config);
+      messageBroker.showStatusMessage(MessageUtils.getLocalizedMessage("analysis.message.build_success"));
+      buildBtn.setEnabled(false);
+    });
+
+  }
+
+  private void addCharFilter() {
+    if (Objects.isNull(cfFactoryCombo.getSelectedItem()) || cfFactoryCombo.getSelectedItem() == "") {
+      return;
+    }
+
+    int targetIndex = selectedCfList.getModel().getSize();
+    String selectedItem = (String) cfFactoryCombo.getSelectedItem();
+    List<String> updatedList = ListUtils.getAllItems(selectedCfList);
+    updatedList.add(selectedItem);
+    cfParamsList.add(new HashMap<>());
+
+    assert selectedCfList.getModel().getSize() == cfParamsList.size();
+
+    showEditParamsDialog(MessageUtils.getLocalizedMessage("analysis.dialog.title.char_filter_params"),
+        EditParamsMode.CHARFILTER, targetIndex, selectedItem, cfParamsList.get(cfParamsList.size() - 1),
+        () -> {
+          selectedCfList.setModel(new DefaultComboBoxModel<>(updatedList.toArray(new String[0])));
+          cfFactoryCombo.setSelectedItem("");
+          cfEditBtn.setEnabled(true);
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void setTokenizer() {
+    if (Objects.isNull(tokFactoryCombo.getSelectedItem()) || tokFactoryCombo.getSelectedItem() == "") {
+      return;
+    }
+
+    String selectedItem = (String) tokFactoryCombo.getSelectedItem();
+    showEditParamsDialog(MessageUtils.getLocalizedMessage("analysis.dialog.title.tokenizer_params"),
+        EditParamsMode.TOKENIZER, -1, selectedItem, Collections.emptyMap(),
+        () -> {
+          selectedTokTF.setText(selectedItem);
+          tokFactoryCombo.setSelectedItem("");
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void addTokenFilter() {
+    if (Objects.isNull(tfFactoryCombo.getSelectedItem()) || tfFactoryCombo.getSelectedItem() == "") {
+      return;
+    }
+
+    int targetIndex = selectedTfList.getModel().getSize();
+    String selectedItem = (String) tfFactoryCombo.getSelectedItem();
+    List<String> updatedList = ListUtils.getAllItems(selectedTfList);
+    updatedList.add(selectedItem);
+    tfParamsList.add(new HashMap<>());
+
+    assert selectedTfList.getModel().getSize() == tfParamsList.size();
+
+    showEditParamsDialog(MessageUtils.getLocalizedMessage("analysis.dialog.title.token_filter_params"),
+        EditParamsMode.TOKENFILTER, targetIndex, selectedItem, tfParamsList.get(tfParamsList.size() - 1),
+        () -> {
+          selectedTfList.setModel(new DefaultComboBoxModel<>(updatedList.toArray(new String[updatedList.size()])));
+          tfFactoryCombo.setSelectedItem("");
+          tfEditBtn.setEnabled(true);
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void showEditParamsDialog(String title, EditParamsMode mode, int targetIndex, String selectedItem, Map<String, String> params, Callable callback) {
+    new DialogOpener<>(editParamsDialogFactory).open(title, 400, 300,
+        (factory) -> {
+          factory.setMode(mode);
+          factory.setTargetIndex(targetIndex);
+          factory.setTarget(selectedItem);
+          factory.setParams(params);
+          factory.setCallback(callback);
+        });
+  }
+
+  private void editCharFilters() {
+    List<String> filters = ListUtils.getAllItems(selectedCfList);
+    showEditFiltersDialog(EditFiltersMode.CHARFILTER, filters,
+        () -> {
+          cfEditBtn.setEnabled(selectedCfList.getModel().getSize() > 0);
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void editTokenizer() {
+    String selectedItem = selectedTokTF.getText();
+    showEditParamsDialog(MessageUtils.getLocalizedMessage("analysis.dialog.title.tokenizer_params"),
+        EditParamsMode.TOKENIZER, -1, selectedItem, tokParams, () -> {
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void editTokenFilters() {
+    List<String> filters = ListUtils.getAllItems(selectedTfList);
+    showEditFiltersDialog(EditFiltersMode.TOKENFILTER, filters,
+        () -> {
+          tfEditBtn.setEnabled(selectedTfList.getModel().getSize() > 0);
+          buildBtn.setEnabled(true);
+        });
+  }
+
+  private void showEditFiltersDialog(EditFiltersMode mode, List<String> selectedFilters, Callable callback) {
+    String title = (mode == EditFiltersMode.CHARFILTER) ?
+        MessageUtils.getLocalizedMessage("analysis.dialog.title.selected_char_filter") :
+        MessageUtils.getLocalizedMessage("analysis.dialog.title.selected_token_filter");
+    new DialogOpener<>(editFiltersDialogFactory).open(title, 400, 300,
+        (factory) -> {
+          factory.setMode(mode);
+          factory.setSelectedFilters(selectedFilters);
+          factory.setCallback(callback);
+        });
+  }
+
+  @Override
+  public void setAnalysisModel(Analysis model) {
+    analysisModel = model;
+  }
+
+  @Override
+  public void resetAnalysisComponents() {
+    setAvailableCharFilterFactories();
+    setAvailableTokenizerFactories();
+    setAvailableTokenFilterFactories();
+    buildBtn.setEnabled(true);
+  }
+
+  private void setAvailableCharFilterFactories() {
+    Collection<String> charFilters = analysisModel.getAvailableCharFilters();
+    String[] charFilterNames = new String[charFilters.size() + 1];
+    charFilterNames[0] = "";
+    System.arraycopy(charFilters.toArray(new String[0]), 0, charFilterNames, 1, charFilters.size());
+    cfFactoryCombo.setModel(new DefaultComboBoxModel<>(charFilterNames));
+  }
+
+  private void setAvailableTokenizerFactories() {
+    Collection<String> tokenizers = analysisModel.getAvailableTokenizers();
+    String[] tokenizerNames = new String[tokenizers.size() + 1];
+    tokenizerNames[0] = "";
+    System.arraycopy(tokenizers.toArray(new String[0]), 0, tokenizerNames, 1, tokenizers.size());
+    tokFactoryCombo.setModel(new DefaultComboBoxModel<>(tokenizerNames));
+  }
+
+  private void setAvailableTokenFilterFactories() {
+    Collection<String> tokenFilters = analysisModel.getAvailableTokenFilters();
+    String[] tokenFilterNames = new String[tokenFilters.size() + 1];
+    tokenFilterNames[0] = "";
+    System.arraycopy(tokenFilters.toArray(new String[0]), 0, tokenFilterNames, 1, tokenFilters.size());
+    tfFactoryCombo.setModel(new DefaultComboBoxModel<>(tokenFilterNames));
+  }
+
+  @Override
+  public void updateCharFilters(List<Integer> deletedIndexes) {
+    // update filters
+    List<String> filters = ListUtils.getAllItems(selectedCfList);
+    String[] updatedFilters = IntStream.range(0, filters.size())
+        .filter(i -> !deletedIndexes.contains(i))
+        .mapToObj(filters::get)
+        .toArray(String[]::new);
+    selectedCfList.setModel(new DefaultComboBoxModel<>(updatedFilters));
+    // update parameters map for each filter
+    List<Map<String, String>> updatedParamList = IntStream.range(0, cfParamsList.size())
+        .filter(i -> !deletedIndexes.contains(i))
+        .mapToObj(cfParamsList::get)
+        .collect(Collectors.toList());
+    cfParamsList.clear();
+    cfParamsList.addAll(updatedParamList);
+    assert selectedCfList.getModel().getSize() == cfParamsList.size();
+  }
+
+  @Override
+  public void updateTokenFilters(List<Integer> deletedIndexes) {
+    // update filters
+    List<String> filters = ListUtils.getAllItems(selectedTfList);
+    String[] updatedFilters = IntStream.range(0, filters.size())
+        .filter(i -> !deletedIndexes.contains(i))
+        .mapToObj(filters::get)
+        .toArray(String[]::new);
+    selectedTfList.setModel(new DefaultComboBoxModel<>(updatedFilters));
+    // update parameters map for each filter
+    List<Map<String, String>> updatedParamList = IntStream.range(0, tfParamsList.size())
+        .filter(i -> !deletedIndexes.contains(i))
+        .mapToObj(tfParamsList::get)
+        .collect(Collectors.toList());
+    tfParamsList.clear();
+    tfParamsList.addAll(updatedParamList);
+    assert selectedTfList.getModel().getSize() == tfParamsList.size();
+  }
+
+  @Override
+  public Map<String, String> getCharFilterParams(int index) {
+    if (index < 0 || index > cfParamsList.size()) {
+      throw new IllegalArgumentException();
+    }
+    return Collections.unmodifiableMap(cfParamsList.get(index));
+  }
+
+  @Override
+  public void updateCharFilterParams(int index, Map<String, String> updatedParams) {
+    if (index < 0 || index > cfParamsList.size()) {
+      throw new IllegalArgumentException();
+    }
+    if (index == cfParamsList.size()) {
+      cfParamsList.add(new HashMap<>());
+    }
+    cfParamsList.get(index).clear();
+    cfParamsList.get(index).putAll(updatedParams);
+  }
+
+  @Override
+  public void updateTokenizerParams(Map<String, String> updatedParams) {
+    tokParams.clear();
+    tokParams.putAll(updatedParams);
+  }
+
+  @Override
+  public Map<String, String> getTokenFilterParams(int index) {
+    if (index < 0 || index > tfParamsList.size()) {
+      throw new IllegalArgumentException();
+    }
+    return Collections.unmodifiableMap(tfParamsList.get(index));
+  }
+
+  @Override
+  public void updateTokenFilterParams(int index, Map<String, String> updatedParams) {
+    if (index < 0 || index > tfParamsList.size()) {
+      throw new IllegalArgumentException();
+    }
+    if (index == tfParamsList.size()) {
+      tfParamsList.add(new HashMap<>());
+    }
+    tfParamsList.get(index).clear();
+    tfParamsList.get(index).putAll(updatedParams);
+  }
+
+  private class ListenerFunctions {
+
+    void chooseConfigDir(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.chooseConfigDir();
+    }
+
+    void loadExternalJars(MouseEvent e) {
+      CustomAnalyzerPanelProvider.this.loadExternalJars();
+    }
+
+    void buildAnalyzer(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.buildAnalyzer();
+    }
+
+    void addCharFilter(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.addCharFilter();
+    }
+
+    void setTokenizer(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.setTokenizer();
+    }
+
+    void addTokenFilter(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.addTokenFilter();
+    }
+
+    void editCharFilters(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.editCharFilters();
+    }
+
+    void editTokenizer(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.editTokenizer();
+    }
+
+    void editTokenFilters(ActionEvent e) {
+      CustomAnalyzerPanelProvider.this.editTokenFilters();
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelOperator.java
new file mode 100644
index 00000000000..856de6357e1
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelOperator.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.analysis;
+
+import java.util.Collection;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+
+/** Operator of the preset analyzer panel */
+public interface PresetAnalyzerPanelOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setPresetAnalyzers(Collection<Class<? extends Analyzer>> presetAnalyzers);
+
+  void setSelectedAnalyzer(Class<? extends Analyzer> analyzer);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelProvider.java
new file mode 100644
index 00000000000..f8210821a3a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/PresetAnalyzerPanelProvider.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.analysis;
+
+import javax.swing.BorderFactory;
+import javax.swing.ComboBoxModel;
+import javax.swing.DefaultComboBoxModel;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.app.desktop.components.AnalysisTabOperator;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Provider of the preset analyzer panel */
+public final class PresetAnalyzerPanelProvider implements PresetAnalyzerPanelOperator {
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private final JComboBox<String> analyzersCB = new JComboBox<>();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  public PresetAnalyzerPanelProvider() {
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    operatorRegistry.register(PresetAnalyzerPanelOperator.class, this);
+  }
+
+  public JPanel get() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    JLabel header = new JLabel(MessageUtils.getLocalizedMessage("analysis_preset.label.preset"));
+    panel.add(header, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    center.setOpaque(false);
+    center.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+    center.setPreferredSize(new Dimension(400, 40));
+    analyzersCB.addActionListener(listeners::setAnalyzer);
+    analyzersCB.setEnabled(false);
+    center.add(analyzersCB);
+    panel.add(center, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  // control methods
+
+  @Override
+  public void setPresetAnalyzers(Collection<Class<? extends Analyzer>> presetAnalyzers) {
+    String[] analyzerNames = presetAnalyzers.stream().map(Class::getName).toArray(String[]::new);
+    ComboBoxModel<String> model = new DefaultComboBoxModel<>(analyzerNames);
+    analyzersCB.setModel(model);
+    analyzersCB.setEnabled(true);
+  }
+
+  @Override
+  public void setSelectedAnalyzer(Class<? extends Analyzer> analyzer) {
+    analyzersCB.setSelectedItem(analyzer.getName());
+  }
+
+  private class ListenerFunctions {
+
+    void setAnalyzer(ActionEvent e) {
+      operatorRegistry.get(AnalysisTabOperator.class).ifPresent(operator ->
+          operator.setAnalyzerByType((String) analyzersCB.getSelectedItem())
+      );
+    }
+
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/package-info.java
new file mode 100644
index 00000000000..20cbe7b84f5
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/analysis/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** UI parts embedded in the Analysis tab */
+package org.apache.lucene.luke.app.desktop.components.fragments.analysis;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/package-info.java
new file mode 100644
index 00000000000..382d73aaf69
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** UI parts embedded in tabs */
+package org.apache.lucene.luke.app.desktop.components.fragments;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerPaneProvider.java
new file mode 100644
index 00000000000..9f74a4df323
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerPaneProvider.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.DefaultListModel;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JTextField;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.custom.CustomAnalyzer;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TabSwitcherProxy;
+import org.apache.lucene.luke.app.desktop.components.TabbedPaneProvider;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+
+/** Provider of the Analyzer pane */
+public final class AnalyzerPaneProvider implements AnalyzerTabOperator {
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final JLabel analyzerNameLbl = new JLabel(StandardAnalyzer.class.getName());
+
+  private final JList<String> charFilterList = new JList<>();
+
+  private final JTextField tokenizerTF = new JTextField();
+
+  private final JList<String> tokenFilterList = new JList<>();
+
+  public AnalyzerPaneProvider() {
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+
+    ComponentOperatorRegistry.getInstance().register(AnalyzerTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    panel.add(initAnalyzerNamePanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initAnalysisChainPanel());
+
+    tokenizerTF.setEditable(false);
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initAnalyzerNamePanel() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    panel.setOpaque(false);
+
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.label.name")));
+
+    panel.add(analyzerNameLbl);
+
+    JLabel changeLbl = new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.hyperlink.change"));
+    changeLbl.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        tabSwitcher.switchTab(TabbedPaneProvider.Tab.ANALYZER);
+      }
+    });
+    panel.add(FontUtils.toLinkText(changeLbl));
+
+    return panel;
+  }
+
+  private JPanel initAnalysisChainPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel top = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    top.setOpaque(false);
+    top.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+    top.add(new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.label.chain")));
+    panel.add(top, BorderLayout.PAGE_START);
+
+    JPanel center = new JPanel(new GridBagLayout());
+    center.setOpaque(false);
+
+    GridBagConstraints c = new GridBagConstraints();
+    c.fill = GridBagConstraints.BOTH;
+    c.insets = new Insets(5, 5, 5, 5);
+
+    c.gridx = 0;
+    c.gridy = 0;
+    c.weightx = 0.1;
+    center.add(new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.label.charfilters")), c);
+
+    charFilterList.setVisibleRowCount(3);
+    JScrollPane charFilterSP = new JScrollPane(charFilterList);
+    c.gridx = 1;
+    c.gridy = 0;
+    c.weightx = 0.5;
+    center.add(charFilterSP, c);
+
+    c.gridx = 0;
+    c.gridy = 1;
+    c.weightx = 0.1;
+    center.add(new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.label.tokenizer")), c);
+
+    tokenizerTF.setColumns(30);
+    tokenizerTF.setPreferredSize(new Dimension(400, 25));
+    tokenizerTF.setBorder(BorderFactory.createLineBorder(Color.gray));
+    c.gridx = 1;
+    c.gridy = 1;
+    c.weightx = 0.5;
+    center.add(tokenizerTF, c);
+
+    c.gridx = 0;
+    c.gridy = 2;
+    c.weightx = 0.1;
+    center.add(new JLabel(MessageUtils.getLocalizedMessage("search_analyzer.label.tokenfilters")), c);
+
+    tokenFilterList.setVisibleRowCount(3);
+    JScrollPane tokenFilterSP = new JScrollPane(tokenFilterList);
+    c.gridx = 1;
+    c.gridy = 2;
+    c.weightx = 0.5;
+    center.add(tokenFilterSP, c);
+
+    panel.add(center, BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  @Override
+  public void setAnalyzer(Analyzer analyzer) {
+    analyzerNameLbl.setText(analyzer.getClass().getName());
+
+    if (analyzer instanceof CustomAnalyzer) {
+      CustomAnalyzer customAnalyzer = (CustomAnalyzer) analyzer;
+
+      DefaultListModel<String> charFilterListModel = new DefaultListModel<>();
+      customAnalyzer.getCharFilterFactories().stream()
+          .map(f -> f.getClass().getSimpleName())
+          .forEach(charFilterListModel::addElement);
+      charFilterList.setModel(charFilterListModel);
+
+      tokenizerTF.setText(customAnalyzer.getTokenizerFactory().getClass().getSimpleName());
+
+      DefaultListModel<String> tokenFilterListModel = new DefaultListModel<>();
+      customAnalyzer.getTokenFilterFactories().stream()
+          .map(f -> f.getClass().getSimpleName())
+          .forEach(tokenFilterListModel::addElement);
+      tokenFilterList.setModel(tokenFilterListModel);
+
+      charFilterList.setBackground(Color.white);
+      tokenizerTF.setBackground(Color.white);
+      tokenFilterList.setBackground(Color.white);
+    } else {
+      charFilterList.setModel(new DefaultListModel<>());
+      tokenizerTF.setText("");
+      tokenFilterList.setModel(new DefaultListModel<>());
+
+      charFilterList.setBackground(Color.lightGray);
+      tokenizerTF.setBackground(Color.lightGray);
+      tokenFilterList.setBackground(Color.lightGray);
+    }
+  }
+
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerTabOperator.java
new file mode 100644
index 00000000000..55aec09566f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/AnalyzerTabOperator.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+
+/** Operator for the Analyzer tab */
+public interface AnalyzerTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setAnalyzer(Analyzer analyzer);
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesPaneProvider.java
new file mode 100644
index 00000000000..1217bf90329
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesPaneProvider.java
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import javax.swing.event.TableModelEvent;
+import java.awt.BorderLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+
+/** Provider of the FieldValues pane */
+public final class FieldValuesPaneProvider implements FieldValuesTabOperator {
+
+  private final JCheckBox loadAllCB = new JCheckBox();
+
+  private final JTable fieldsTable = new JTable();
+
+  private ListenerFunctions listners = new ListenerFunctions();
+
+  public FieldValuesPaneProvider() {
+    ComponentOperatorRegistry.getInstance().register(FieldValuesTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(initFieldsConfigPanel());
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initFieldsConfigPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+
+    JPanel header = new JPanel(new GridLayout(1, 2));
+    header.setOpaque(false);
+    header.setBorder(BorderFactory.createEmptyBorder(0, 0, 10, 0));
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_values.label.description")));
+    loadAllCB.setText(MessageUtils.getLocalizedMessage("search_values.checkbox.load_all"));
+    loadAllCB.setSelected(true);
+    loadAllCB.addActionListener(listners::loadAllFields);
+    loadAllCB.setOpaque(false);
+    header.add(loadAllCB);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(fieldsTable, ListSelectionModel.SINGLE_SELECTION, new FieldsTableModel(), null,
+        FieldsTableModel.Column.LOAD.getColumnWidth());
+    fieldsTable.setShowGrid(true);
+    fieldsTable.setPreferredScrollableViewportSize(fieldsTable.getPreferredSize());
+    panel.add(new JScrollPane(fieldsTable), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  @Override
+  public void setFields(Collection<String> fields) {
+    fieldsTable.setModel(new FieldsTableModel(fields));
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.LOAD.getIndex()).setMinWidth(FieldsTableModel.Column.LOAD.getColumnWidth());
+    fieldsTable.getColumnModel().getColumn(FieldsTableModel.Column.LOAD.getIndex()).setMaxWidth(FieldsTableModel.Column.LOAD.getColumnWidth());
+    fieldsTable.getModel().addTableModelListener(listners::tableDataChenged);
+  }
+
+  @Override
+  public Set<String> getFieldsToLoad() {
+    Set<String> fieldsToLoad = new HashSet<>();
+    for (int row = 0; row < fieldsTable.getRowCount(); row++) {
+      boolean loaded = (boolean) fieldsTable.getValueAt(row, FieldsTableModel.Column.LOAD.getIndex());
+      if (loaded) {
+        fieldsToLoad.add((String) fieldsTable.getValueAt(row, FieldsTableModel.Column.FIELD.getIndex()));
+      }
+    }
+    return fieldsToLoad;
+  }
+
+  class ListenerFunctions {
+
+    void loadAllFields(ActionEvent e) {
+      for (int i = 0; i < fieldsTable.getModel().getRowCount(); i++) {
+        if (loadAllCB.isSelected()) {
+          fieldsTable.setValueAt(true, i, FieldsTableModel.Column.LOAD.getIndex());
+        } else {
+          fieldsTable.setValueAt(false, i, FieldsTableModel.Column.LOAD.getIndex());
+        }
+      }
+    }
+
+    void tableDataChenged(TableModelEvent e) {
+      int row = e.getFirstRow();
+      int col = e.getColumn();
+      if (col == FieldsTableModel.Column.LOAD.getIndex()) {
+        boolean isLoad = (boolean) fieldsTable.getModel().getValueAt(row, col);
+        if (!isLoad) {
+          loadAllCB.setSelected(false);
+        }
+      }
+    }
+  }
+
+  static final class FieldsTableModel extends TableModelBase<FieldsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      LOAD("Load", 0, Boolean.class, 50),
+      FIELD("Field", 1, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    FieldsTableModel() {
+      super();
+    }
+
+    FieldsTableModel(Collection<String> fields) {
+      super(fields.size());
+      int i = 0;
+      for (String field : fields) {
+        data[i][Column.LOAD.getIndex()] = true;
+        data[i][Column.FIELD.getIndex()] = field;
+        i++;
+      }
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return columnIndex == Column.LOAD.getIndex();
+    }
+
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+      fireTableCellUpdated(rowIndex, columnIndex);
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesTabOperator.java
new file mode 100644
index 00000000000..0b317651c06
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/FieldValuesTabOperator.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import java.util.Collection;
+import java.util.Set;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+
+/** Operator of the FieldValues tab */
+public interface FieldValuesTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setFields(Collection<String> fields);
+
+  Set<String> getFieldsToLoad();
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTPaneProvider.java
new file mode 100644
index 00000000000..ad791a40347
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTPaneProvider.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JCheckBox;
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+import javax.swing.event.TableModelEvent;
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TabSwitcherProxy;
+import org.apache.lucene.luke.app.desktop.components.TabbedPaneProvider;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.components.fragments.search.FieldValuesPaneProvider.FieldsTableModel;
+import org.apache.lucene.luke.app.desktop.util.FontUtils;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.search.MLTConfig;
+
+/** Provider of the MLT pane */
+public final class MLTPaneProvider implements MLTTabOperator {
+
+  private final JLabel analyzerLbl = new JLabel(StandardAnalyzer.class.getName());
+
+  private final JFormattedTextField maxDocFreqFTF = new JFormattedTextField();
+
+  private final JFormattedTextField minDocFreqFTF = new JFormattedTextField();
+
+  private final JFormattedTextField minTermFreqFTF = new JFormattedTextField();
+
+  private final JCheckBox loadAllCB = new JCheckBox();
+
+  private final JTable fieldsTable = new JTable();
+
+  private final TabSwitcherProxy tabSwitcher;
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private MLTConfig config = new MLTConfig.Builder().build();
+
+  public MLTPaneProvider() {
+    this.tabSwitcher = TabSwitcherProxy.getInstance();
+
+    ComponentOperatorRegistry.getInstance().register(MLTTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    panel.add(initMltParamsPanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initAnalyzerNamePanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initFieldsSettingsPanel());
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initMltParamsPanel() {
+    JPanel panel = new JPanel(new GridLayout(3, 1));
+    panel.setOpaque(false);
+
+    JPanel maxDocFreq = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    maxDocFreq.setOpaque(false);
+    maxDocFreq.add(new JLabel(MessageUtils.getLocalizedMessage("search_mlt.label.max_doc_freq")));
+    maxDocFreqFTF.setColumns(10);
+    maxDocFreqFTF.setValue(config.getMaxDocFreq());
+    maxDocFreq.add(maxDocFreqFTF);
+    maxDocFreq.add(new JLabel(MessageUtils.getLocalizedMessage("label.int_required")));
+    panel.add(maxDocFreq);
+
+    JPanel minDocFreq = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    minDocFreq.setOpaque(false);
+    minDocFreq.add(new JLabel(MessageUtils.getLocalizedMessage("search_mlt.label.min_doc_freq")));
+    minDocFreqFTF.setColumns(5);
+    minDocFreqFTF.setValue(config.getMinDocFreq());
+    minDocFreq.add(minDocFreqFTF);
+
+    minDocFreq.add(new JLabel(MessageUtils.getLocalizedMessage("label.int_required")));
+    panel.add(minDocFreq);
+
+    JPanel minTermFreq = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    minTermFreq.setOpaque(false);
+    minTermFreq.add(new JLabel(MessageUtils.getLocalizedMessage("serach_mlt.label.min_term_freq")));
+    minTermFreqFTF.setColumns(5);
+    minTermFreqFTF.setValue(config.getMinTermFreq());
+    minTermFreq.add(minTermFreqFTF);
+    minTermFreq.add(new JLabel(MessageUtils.getLocalizedMessage("label.int_required")));
+    panel.add(minTermFreq);
+
+    return panel;
+  }
+
+  private JPanel initAnalyzerNamePanel() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    panel.setOpaque(false);
+
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("search_mlt.label.analyzer")));
+
+    panel.add(analyzerLbl);
+
+    JLabel changeLbl = new JLabel(MessageUtils.getLocalizedMessage("search_mlt.hyperlink.change"));
+    changeLbl.addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        tabSwitcher.switchTab(TabbedPaneProvider.Tab.ANALYZER);
+      }
+    });
+    panel.add(FontUtils.toLinkText(changeLbl));
+
+    return panel;
+  }
+
+  private JPanel initFieldsSettingsPanel() {
+    JPanel panel = new JPanel(new BorderLayout());
+    panel.setOpaque(false);
+    panel.setPreferredSize(new Dimension(500, 300));
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    JPanel header = new JPanel(new GridLayout(2, 1));
+    header.setOpaque(false);
+    header.setBorder(BorderFactory.createEmptyBorder(0, 0, 10, 0));
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_mlt.label.description")));
+    loadAllCB.setText(MessageUtils.getLocalizedMessage("search_mlt.checkbox.select_all"));
+    loadAllCB.setSelected(true);
+    loadAllCB.addActionListener(listeners::loadAllFields);
+    loadAllCB.setOpaque(false);
+    header.add(loadAllCB);
+    panel.add(header, BorderLayout.PAGE_START);
+
+    TableUtils.setupTable(fieldsTable, ListSelectionModel.SINGLE_SELECTION, new MLTFieldsTableModel(), null, MLTFieldsTableModel.Column.SELECT.getColumnWidth());
+    fieldsTable.setPreferredScrollableViewportSize(fieldsTable.getPreferredSize());
+    panel.add(new JScrollPane(fieldsTable), BorderLayout.CENTER);
+
+    return panel;
+  }
+
+  @Override
+  public void setAnalyzer(Analyzer analyzer) {
+    analyzerLbl.setText(analyzer.getClass().getName());
+  }
+
+  @Override
+  public void setFields(Collection<String> fields) {
+    fieldsTable.setModel(new MLTFieldsTableModel(fields));
+    fieldsTable.getColumnModel().getColumn(MLTFieldsTableModel.Column.SELECT.getIndex()).setMinWidth(MLTFieldsTableModel.Column.SELECT.getColumnWidth());
+    fieldsTable.getColumnModel().getColumn(MLTFieldsTableModel.Column.SELECT.getIndex()).setMaxWidth(MLTFieldsTableModel.Column.SELECT.getColumnWidth());
+    fieldsTable.getModel().addTableModelListener(listeners::tableDataChenged);
+  }
+
+  @Override
+  public MLTConfig getConfig() {
+    List<String> fields = new ArrayList<>();
+    for (int row = 0; row < fieldsTable.getRowCount(); row++) {
+      boolean selected = (boolean) fieldsTable.getValueAt(row, MLTFieldsTableModel.Column.SELECT.getIndex());
+      if (selected) {
+        fields.add((String) fieldsTable.getValueAt(row, MLTFieldsTableModel.Column.FIELD.getIndex()));
+      }
+    }
+
+    return new MLTConfig.Builder()
+        .fields(fields)
+        .maxDocFreq((int) maxDocFreqFTF.getValue())
+        .minDocFreq((int) minDocFreqFTF.getValue())
+        .minTermFreq((int) minTermFreqFTF.getValue())
+        .build();
+  }
+
+  private class ListenerFunctions {
+
+    void loadAllFields(ActionEvent e) {
+      for (int i = 0; i < fieldsTable.getModel().getRowCount(); i++) {
+        if (loadAllCB.isSelected()) {
+          fieldsTable.setValueAt(true, i, FieldsTableModel.Column.LOAD.getIndex());
+        } else {
+          fieldsTable.setValueAt(false, i, FieldsTableModel.Column.LOAD.getIndex());
+        }
+      }
+    }
+
+    void tableDataChenged(TableModelEvent e) {
+      int row = e.getFirstRow();
+      int col = e.getColumn();
+      if (col == MLTFieldsTableModel.Column.SELECT.getIndex()) {
+        boolean isLoad = (boolean) fieldsTable.getModel().getValueAt(row, col);
+        if (!isLoad) {
+          loadAllCB.setSelected(false);
+        }
+      }
+    }
+  }
+
+  static final class MLTFieldsTableModel extends TableModelBase<MLTFieldsTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+      SELECT("Select", 0, Boolean.class, 50),
+      FIELD("Field", 1, String.class, Integer.MAX_VALUE);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    MLTFieldsTableModel() {
+      super();
+    }
+
+    MLTFieldsTableModel(Collection<String> fields) {
+      super(fields.size());
+      int i = 0;
+      for (String field : fields) {
+        data[i][Column.SELECT.getIndex()] = true;
+        data[i][Column.FIELD.getIndex()] = field;
+        i++;
+      }
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return columnIndex == Column.SELECT.getIndex();
+    }
+
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+      fireTableCellUpdated(rowIndex, columnIndex);
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+}
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTTabOperator.java
new file mode 100644
index 00000000000..1180bc772d0
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/MLTTabOperator.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import java.util.Collection;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.models.search.MLTConfig;
+
+/** Operator of the MLT tab */
+public interface MLTTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setAnalyzer(Analyzer analyzer);
+
+  void setFields(Collection<String> fields);
+
+  MLTConfig getConfig();
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserPaneProvider.java
new file mode 100644
index 00000000000..f565339853d
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserPaneProvider.java
@@ -0,0 +1,513 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.DefaultCellEditor;
+import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+import java.awt.Color;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+
+import org.apache.lucene.document.DateTools;
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+import org.apache.lucene.luke.app.desktop.components.TableModelBase;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.TableUtils;
+import org.apache.lucene.luke.models.search.QueryParserConfig;
+
+/** Provider of the QueryParser pane (tab) */
+public final class QueryParserPaneProvider implements QueryParserTabOperator {
+
+  private final JRadioButton standardRB = new JRadioButton();
+
+  private final JRadioButton classicRB = new JRadioButton();
+
+  private final JComboBox<String> dfCB = new JComboBox<>();
+
+  private final JComboBox<String> defOpCombo = new JComboBox<>(new String[]{QueryParserConfig.Operator.OR.name(), QueryParserConfig.Operator.AND.name()});
+
+  private final JCheckBox posIncCB = new JCheckBox();
+
+  private final JCheckBox wildCardCB = new JCheckBox();
+
+  private final JCheckBox splitWSCB = new JCheckBox();
+
+  private final JCheckBox genPhraseQueryCB = new JCheckBox();
+
+  private final JCheckBox genMultiTermSynonymsPhraseQueryCB = new JCheckBox();
+
+  private final JFormattedTextField slopFTF = new JFormattedTextField();
+
+  private final JFormattedTextField minSimFTF = new JFormattedTextField();
+
+  private final JFormattedTextField prefLenFTF = new JFormattedTextField();
+
+  private final JComboBox<String> dateResCB = new JComboBox<>();
+
+  private final JTextField locationTF = new JTextField();
+
+  private final JTextField timezoneTF = new JTextField();
+
+  private final JTable pointRangeQueryTable = new JTable();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private final QueryParserConfig config = new QueryParserConfig.Builder().build();
+
+  public QueryParserPaneProvider() {
+    ComponentOperatorRegistry.getInstance().register(QueryParserTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+
+    panel.add(initSelectParserPane());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initParserSettingsPanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initPhraseQuerySettingsPanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initFuzzyQuerySettingsPanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initDateRangeQuerySettingsPanel());
+    panel.add(new JSeparator(JSeparator.HORIZONTAL));
+    panel.add(initPointRangeQuerySettingsPanel());
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initSelectParserPane() {
+    JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    panel.setOpaque(false);
+
+    standardRB.setText("StandardQueryParser");
+    standardRB.setSelected(true);
+    standardRB.addActionListener(listeners::selectStandardQParser);
+    standardRB.setOpaque(false);
+
+    classicRB.setText("Classic QueryParser");
+    classicRB.addActionListener(listeners::selectClassicQparser);
+    classicRB.setOpaque(false);
+
+    ButtonGroup group = new ButtonGroup();
+    group.add(standardRB);
+    group.add(classicRB);
+
+    panel.add(standardRB);
+    panel.add(classicRB);
+
+    return panel;
+  }
+
+  private JPanel initParserSettingsPanel() {
+    JPanel panel = new JPanel(new GridLayout(3, 2));
+    panel.setOpaque(false);
+
+    JPanel defField = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    defField.setOpaque(false);
+    JLabel dfLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.df"));
+    defField.add(dfLabel);
+    defField.add(dfCB);
+    panel.add(defField);
+
+    JPanel defOp = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    defOp.setOpaque(false);
+    JLabel defOpLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.dop"));
+    defOp.add(defOpLabel);
+    defOpCombo.setSelectedItem(config.getDefaultOperator().name());
+    defOp.add(defOpCombo);
+    panel.add(defOp);
+
+    posIncCB.setText(MessageUtils.getLocalizedMessage("search_parser.checkbox.pos_incr"));
+    posIncCB.setSelected(config.isEnablePositionIncrements());
+    posIncCB.setOpaque(false);
+    panel.add(posIncCB);
+
+    wildCardCB.setText(MessageUtils.getLocalizedMessage("search_parser.checkbox.lead_wildcard"));
+    wildCardCB.setSelected(config.isAllowLeadingWildcard());
+    wildCardCB.setOpaque(false);
+    panel.add(wildCardCB);
+
+    splitWSCB.setText(MessageUtils.getLocalizedMessage("search_parser.checkbox.split_ws"));
+    splitWSCB.setEnabled(config.isSplitOnWhitespace());
+    splitWSCB.addActionListener(listeners::toggleSplitOnWhiteSpace);
+    splitWSCB.setOpaque(false);
+    panel.add(splitWSCB);
+
+    return panel;
+  }
+
+  private JPanel initPhraseQuerySettingsPanel() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.phrase_query")));
+    panel.add(header);
+
+    JPanel genPQ = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    genPQ.setOpaque(false);
+    genPQ.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    genPhraseQueryCB.setText(MessageUtils.getLocalizedMessage("search_parser.checkbox.gen_pq"));
+    genPhraseQueryCB.setEnabled(config.isAutoGeneratePhraseQueries());
+    genPhraseQueryCB.setOpaque(false);
+    genPQ.add(genPhraseQueryCB);
+    panel.add(genPQ);
+
+    JPanel genMTPQ = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    genMTPQ.setOpaque(false);
+    genMTPQ.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    genMultiTermSynonymsPhraseQueryCB.setText(MessageUtils.getLocalizedMessage("search_parser.checkbox.gen_mts"));
+    genMultiTermSynonymsPhraseQueryCB.setEnabled(config.isAutoGenerateMultiTermSynonymsPhraseQuery());
+    genMultiTermSynonymsPhraseQueryCB.setOpaque(false);
+    genMTPQ.add(genMultiTermSynonymsPhraseQueryCB);
+    panel.add(genMTPQ);
+
+    JPanel slop = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    slop.setOpaque(false);
+    slop.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    JLabel slopLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.phrase_slop"));
+    slop.add(slopLabel);
+    slopFTF.setColumns(5);
+    slopFTF.setValue(config.getPhraseSlop());
+    slop.add(slopFTF);
+    slop.add(new JLabel(MessageUtils.getLocalizedMessage("label.int_required")));
+    panel.add(slop);
+
+    return panel;
+  }
+
+  private JPanel initFuzzyQuerySettingsPanel() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.fuzzy_query")));
+    panel.add(header);
+
+    JPanel minSim = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    minSim.setOpaque(false);
+    minSim.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    JLabel minSimLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.fuzzy_minsim"));
+    minSim.add(minSimLabel);
+    minSimFTF.setColumns(5);
+    minSimFTF.setValue(config.getFuzzyMinSim());
+    minSim.add(minSimFTF);
+    minSim.add(new JLabel(MessageUtils.getLocalizedMessage("label.float_required")));
+    panel.add(minSim);
+
+    JPanel prefLen = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    prefLen.setOpaque(false);
+    prefLen.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    JLabel prefLenLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.fuzzy_preflen"));
+    prefLen.add(prefLenLabel);
+    prefLenFTF.setColumns(5);
+    prefLenFTF.setValue(config.getFuzzyPrefixLength());
+    prefLen.add(prefLenFTF);
+    prefLen.add(new JLabel(MessageUtils.getLocalizedMessage("label.int_required")));
+    panel.add(prefLen);
+
+    return panel;
+  }
+
+  private JPanel initDateRangeQuerySettingsPanel() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.daterange_query")));
+    panel.add(header);
+
+    JPanel resolution = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    resolution.setOpaque(false);
+    resolution.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    JLabel resLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.date_res"));
+    resolution.add(resLabel);
+    Arrays.stream(DateTools.Resolution.values()).map(DateTools.Resolution::name).forEach(dateResCB::addItem);
+    dateResCB.setSelectedItem(config.getDateResolution().name());
+    dateResCB.setOpaque(false);
+    resolution.add(dateResCB);
+    panel.add(resolution);
+
+    JPanel locale = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    locale.setOpaque(false);
+    locale.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+    JLabel locLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.locale"));
+    locale.add(locLabel);
+    locationTF.setColumns(10);
+    locationTF.setText(config.getLocale().toLanguageTag());
+    locale.add(locationTF);
+    JLabel tzLabel = new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.timezone"));
+    locale.add(tzLabel);
+    timezoneTF.setColumns(10);
+    timezoneTF.setText(config.getTimeZone().getID());
+    locale.add(timezoneTF);
+    panel.add(locale);
+
+    return panel;
+  }
+
+  private JPanel initPointRangeQuerySettingsPanel() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+
+    JPanel header = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    header.setOpaque(false);
+    header.add(new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.pointrange_query")));
+    panel.add(header);
+
+    JPanel headerNote = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    headerNote.setOpaque(false);
+    headerNote.add(new JLabel(MessageUtils.getLocalizedMessage("search_parser.label.pointrange_hint")));
+    panel.add(headerNote);
+
+    TableUtils.setupTable(pointRangeQueryTable, ListSelectionModel.SINGLE_SELECTION, new PointTypesTableModel(), null, PointTypesTableModel.Column.FIELD.getColumnWidth());
+    pointRangeQueryTable.setShowGrid(true);
+    JScrollPane scrollPane = new JScrollPane(pointRangeQueryTable);
+    panel.add(scrollPane);
+
+    return panel;
+  }
+
+  @Override
+  public void setSearchableFields(Collection<String> searchableFields) {
+    dfCB.removeAllItems();
+    for (String field : searchableFields) {
+      dfCB.addItem(field);
+    }
+  }
+
+  @Override
+  public void setRangeSearchableFields(Collection<String> rangeSearchableFields) {
+    pointRangeQueryTable.setModel(new PointTypesTableModel(rangeSearchableFields));
+    pointRangeQueryTable.setShowGrid(true);
+    String[] numTypes = Arrays.stream(PointTypesTableModel.NumType.values())
+        .map(PointTypesTableModel.NumType::name)
+        .toArray(String[]::new);
+    JComboBox<String> numTypesCombo = new JComboBox<>(numTypes);
+    numTypesCombo.setRenderer((list, value, index, isSelected, cellHasFocus) -> new JLabel(value));
+    pointRangeQueryTable.getColumnModel().getColumn(PointTypesTableModel.Column.TYPE.getIndex()).setCellEditor(new DefaultCellEditor(numTypesCombo));
+    pointRangeQueryTable.getColumnModel().getColumn(PointTypesTableModel.Column.TYPE.getIndex()).setCellRenderer(
+        (table, value, isSelected, hasFocus, row, column) -> new JLabel((String) value)
+    );
+    pointRangeQueryTable.getColumnModel().getColumn(PointTypesTableModel.Column.FIELD.getIndex()).setPreferredWidth(PointTypesTableModel.Column.FIELD.getColumnWidth());
+    pointRangeQueryTable.setPreferredScrollableViewportSize(pointRangeQueryTable.getPreferredSize());
+
+    // set default type to Integer
+    for (int i = 0; i < rangeSearchableFields.size(); i++) {
+      pointRangeQueryTable.setValueAt(PointTypesTableModel.NumType.INT.name(), i, PointTypesTableModel.Column.TYPE.getIndex());
+    }
+
+  }
+
+  @Override
+  public QueryParserConfig getConfig() {
+    int phraseSlop = (int) slopFTF.getValue();
+    float fuzzyMinSimFloat = (float) minSimFTF.getValue();
+    int fuzzyPrefLenInt = (int) prefLenFTF.getValue();
+
+    Map<String, Class<? extends Number>> typeMap = new HashMap<>();
+    for (int row = 0; row < pointRangeQueryTable.getModel().getRowCount(); row++) {
+      String field = (String) pointRangeQueryTable.getValueAt(row, PointTypesTableModel.Column.FIELD.getIndex());
+      String type = (String) pointRangeQueryTable.getValueAt(row, PointTypesTableModel.Column.TYPE.getIndex());
+      switch (PointTypesTableModel.NumType.valueOf(type)) {
+        case INT:
+          typeMap.put(field, Integer.class);
+          break;
+        case LONG:
+          typeMap.put(field, Long.class);
+          break;
+        case FLOAT:
+          typeMap.put(field, Float.class);
+          break;
+        case DOUBLE:
+          typeMap.put(field, Double.class);
+          break;
+        default:
+          break;
+      }
+    }
+
+    return new QueryParserConfig.Builder()
+        .useClassicParser(classicRB.isSelected())
+        .defaultOperator(QueryParserConfig.Operator.valueOf((String) defOpCombo.getSelectedItem()))
+        .enablePositionIncrements(posIncCB.isSelected())
+        .allowLeadingWildcard(wildCardCB.isSelected())
+        .splitOnWhitespace(splitWSCB.isSelected())
+        .autoGeneratePhraseQueries(genPhraseQueryCB.isSelected())
+        .autoGenerateMultiTermSynonymsPhraseQuery(genMultiTermSynonymsPhraseQueryCB.isSelected())
+        .phraseSlop(phraseSlop)
+        .fuzzyMinSim(fuzzyMinSimFloat)
+        .fuzzyPrefixLength(fuzzyPrefLenInt)
+        .dateResolution(DateTools.Resolution.valueOf((String) dateResCB.getSelectedItem()))
+        .locale(new Locale(locationTF.getText()))
+        .timeZone(TimeZone.getTimeZone(timezoneTF.getText()))
+        .typeMap(typeMap)
+        .build();
+  }
+
+  @Override
+  public String getDefaultField() {
+    return (String) dfCB.getSelectedItem();
+  }
+
+  private class ListenerFunctions {
+
+    void selectStandardQParser(ActionEvent e) {
+      splitWSCB.setEnabled(false);
+      genPhraseQueryCB.setEnabled(false);
+      genMultiTermSynonymsPhraseQueryCB.setEnabled(false);
+      TableUtils.setEnabled(pointRangeQueryTable, true);
+    }
+
+    void selectClassicQparser(ActionEvent e) {
+      splitWSCB.setEnabled(true);
+      if (splitWSCB.isSelected()) {
+        genPhraseQueryCB.setEnabled(true);
+      } else {
+        genPhraseQueryCB.setEnabled(false);
+        genPhraseQueryCB.setSelected(false);
+      }
+      genMultiTermSynonymsPhraseQueryCB.setEnabled(true);
+      pointRangeQueryTable.setEnabled(false);
+      pointRangeQueryTable.setForeground(Color.gray);
+      TableUtils.setEnabled(pointRangeQueryTable, false);
+    }
+
+    void toggleSplitOnWhiteSpace(ActionEvent e) {
+      if (splitWSCB.isSelected()) {
+        genPhraseQueryCB.setEnabled(true);
+      } else {
+        genPhraseQueryCB.setEnabled(false);
+        genPhraseQueryCB.setSelected(false);
+      }
+    }
+
+  }
+
+  static final class PointTypesTableModel extends TableModelBase<PointTypesTableModel.Column> {
+
+    enum Column implements TableColumnInfo {
+
+      FIELD("Field", 0, String.class, 300),
+      TYPE("Numeric Type", 1, NumType.class, 150);
+
+      private final String colName;
+      private final int index;
+      private final Class<?> type;
+      private final int width;
+
+      Column(String colName, int index, Class<?> type, int width) {
+        this.colName = colName;
+        this.index = index;
+        this.type = type;
+        this.width = width;
+      }
+
+      @Override
+      public String getColName() {
+        return colName;
+      }
+
+      @Override
+      public int getIndex() {
+        return index;
+      }
+
+      @Override
+      public Class<?> getType() {
+        return type;
+      }
+
+      @Override
+      public int getColumnWidth() {
+        return width;
+      }
+    }
+
+    enum NumType {
+
+      INT, LONG, FLOAT, DOUBLE
+
+    }
+
+    PointTypesTableModel() {
+      super();
+    }
+
+    PointTypesTableModel(Collection<String> rangeSearchableFields) {
+      super(rangeSearchableFields.size());
+      int i = 0;
+      for (String field : rangeSearchableFields) {
+        data[i++][Column.FIELD.getIndex()] = field;
+      }
+    }
+
+    @Override
+    public boolean isCellEditable(int rowIndex, int columnIndex) {
+      return columnIndex == Column.TYPE.getIndex();
+    }
+
+    @Override
+    public void setValueAt(Object value, int rowIndex, int columnIndex) {
+      data[rowIndex][columnIndex] = value;
+      fireTableCellUpdated(rowIndex, columnIndex);
+    }
+
+    @Override
+    protected Column[] columnInfos() {
+      return Column.values();
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserTabOperator.java
new file mode 100644
index 00000000000..1a398721703
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/QueryParserTabOperator.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import java.util.Collection;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.models.search.QueryParserConfig;
+
+/** Operator for the QueryParser tab */
+public interface QueryParserTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setSearchableFields(Collection<String> searchableFields);
+
+  void setRangeSearchableFields(Collection<String> rangeSearchableFields);
+
+  QueryParserConfig getConfig();
+
+  String getDefaultField();
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityPaneProvider.java
new file mode 100644
index 00000000000..8c7cd114c69
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityPaneProvider.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JCheckBox;
+import javax.swing.JFormattedTextField;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StyleConstants;
+import org.apache.lucene.luke.models.search.SimilarityConfig;
+
+/** Provider of the Similarity pane */
+public final class SimilarityPaneProvider implements SimilarityTabOperator {
+
+  private final JCheckBox tfidfCB = new JCheckBox();
+
+  private final JCheckBox discardOverlapsCB = new JCheckBox();
+
+  private final JFormattedTextField k1FTF = new JFormattedTextField();
+
+  private final JFormattedTextField bFTF = new JFormattedTextField();
+
+  private final SimilarityConfig config = new SimilarityConfig.Builder().build();
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  public SimilarityPaneProvider() {
+    ComponentOperatorRegistry.getInstance().register(SimilarityTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel();
+    panel.setOpaque(false);
+    panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
+    panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+    panel.add(initSimilaritySettingsPanel());
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initSimilaritySettingsPanel() {
+    JPanel panel = new JPanel(new GridLayout(4, 1));
+    panel.setOpaque(false);
+    panel.setMaximumSize(new Dimension(700, 220));
+
+    tfidfCB.setText(MessageUtils.getLocalizedMessage("search_similarity.checkbox.use_classic"));
+    tfidfCB.addActionListener(listeners::toggleTfIdf);
+    tfidfCB.setOpaque(false);
+    panel.add(tfidfCB);
+
+    discardOverlapsCB.setText(MessageUtils.getLocalizedMessage("search_similarity.checkbox.discount_overlaps"));
+    discardOverlapsCB.setSelected(config.isUseClassicSimilarity());
+    discardOverlapsCB.setOpaque(false);
+    panel.add(discardOverlapsCB);
+
+    JLabel bm25Label = new JLabel(MessageUtils.getLocalizedMessage("search_similarity.label.bm25_params"));
+    panel.add(bm25Label);
+
+    JPanel bm25Params = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    bm25Params.setOpaque(false);
+    bm25Params.setBorder(BorderFactory.createEmptyBorder(0, 20, 0, 0));
+
+    JPanel k1Val = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    k1Val.setOpaque(false);
+    k1Val.add(new JLabel("k1: "));
+    k1FTF.setColumns(5);
+    k1FTF.setValue(config.getK1());
+    k1Val.add(k1FTF);
+    k1Val.add(new JLabel(MessageUtils.getLocalizedMessage("label.float_required")));
+    bm25Params.add(k1Val);
+
+    JPanel bVal = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    bVal.setOpaque(false);
+    bVal.add(new JLabel("b: "));
+    bFTF.setColumns(5);
+    bFTF.setValue(config.getB());
+    bVal.add(bFTF);
+    bVal.add(new JLabel(MessageUtils.getLocalizedMessage("label.float_required")));
+    bm25Params.add(bVal);
+
+    panel.add(bm25Params);
+
+    return panel;
+  }
+
+  @Override
+  public SimilarityConfig getConfig() {
+    float k1 = (float) k1FTF.getValue();
+    float b = (float) bFTF.getValue();
+    return new SimilarityConfig.Builder()
+        .useClassicSimilarity(tfidfCB.isSelected())
+        .discountOverlaps(discardOverlapsCB.isSelected())
+        .k1(k1)
+        .b(b)
+        .build();
+  }
+
+  private class ListenerFunctions {
+
+    void toggleTfIdf(ActionEvent e) {
+      if (tfidfCB.isSelected()) {
+        k1FTF.setEnabled(false);
+        k1FTF.setBackground(StyleConstants.DISABLED_COLOR);
+        bFTF.setEnabled(false);
+        bFTF.setBackground(StyleConstants.DISABLED_COLOR);
+      } else {
+        k1FTF.setEnabled(true);
+        k1FTF.setBackground(Color.white);
+        bFTF.setEnabled(true);
+        bFTF.setBackground(Color.white);
+      }
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityTabOperator.java
new file mode 100644
index 00000000000..7ecd37117ac
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SimilarityTabOperator.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.models.search.SimilarityConfig;
+
+/** Operator for the Similarity tab */
+public interface SimilarityTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  SimilarityConfig getConfig();
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java
new file mode 100644
index 00000000000..d86215971a7
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortPaneProvider.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.app.desktop.components.SearchTabOperator;
+import org.apache.lucene.luke.app.desktop.util.MessageUtils;
+import org.apache.lucene.luke.app.desktop.util.StringUtils;
+import org.apache.lucene.luke.models.search.Search;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.SortedNumericSortField;
+
+/** Provider of the Sort pane */
+public final class SortPaneProvider implements SortTabOperator {
+
+  private static final String COMMAND_FIELD_COMBO1 = "fieldCombo1";
+
+  private static final String COMMAND_FIELD_COMBO2 = "fieldCombo2";
+
+  private final JComboBox<String> fieldCombo1 = new JComboBox<>();
+
+  private final JComboBox<String> typeCombo1 = new JComboBox<>();
+
+  private final JComboBox<String> orderCombo1 = new JComboBox<>(Order.names());
+
+  private final JComboBox<String> fieldCombo2 = new JComboBox<>();
+
+  private final JComboBox<String> typeCombo2 = new JComboBox<>();
+
+  private final JComboBox<String> orderCombo2 = new JComboBox<>(Order.names());
+
+  private final ListenerFunctions listeners = new ListenerFunctions();
+
+  private final ComponentOperatorRegistry operatorRegistry;
+
+  private Search searchModel;
+
+  public SortPaneProvider() {
+    this.operatorRegistry = ComponentOperatorRegistry.getInstance();
+    operatorRegistry.register(SortTabOperator.class, this);
+  }
+
+  public JScrollPane get() {
+    JPanel panel = new JPanel(new GridLayout(1, 1));
+    panel.setOpaque(false);
+    panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+
+    panel.add(initSortConfigsPanel());
+
+    JScrollPane scrollPane = new JScrollPane(panel);
+    scrollPane.setOpaque(false);
+    scrollPane.getViewport().setOpaque(false);
+    return scrollPane;
+  }
+
+  private JPanel initSortConfigsPanel() {
+    JPanel panel = new JPanel(new GridLayout(5, 1));
+    panel.setOpaque(false);
+    panel.setMaximumSize(new Dimension(500, 200));
+
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.primary")));
+
+    JPanel primary = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    primary.setOpaque(false);
+    primary.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
+    primary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.field")));
+    fieldCombo1.setPreferredSize(new Dimension(150, 30));
+    fieldCombo1.setActionCommand(COMMAND_FIELD_COMBO1);
+    fieldCombo1.addActionListener(listeners::changeField);
+    primary.add(fieldCombo1);
+    primary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.type")));
+    typeCombo1.setPreferredSize(new Dimension(130, 30));
+    typeCombo1.addItem("");
+    typeCombo1.setEnabled(false);
+    primary.add(typeCombo1);
+    primary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.order")));
+    orderCombo1.setPreferredSize(new Dimension(100, 30));
+    orderCombo1.setEnabled(false);
+    primary.add(orderCombo1);
+    panel.add(primary);
+
+    panel.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.secondary")));
+
+    JPanel secondary = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    secondary.setOpaque(false);
+    secondary.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
+    secondary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.field")));
+    fieldCombo2.setPreferredSize(new Dimension(150, 30));
+    fieldCombo2.setActionCommand(COMMAND_FIELD_COMBO2);
+    fieldCombo2.addActionListener(listeners::changeField);
+    secondary.add(fieldCombo2);
+    secondary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.type")));
+    typeCombo2.setPreferredSize(new Dimension(130, 30));
+    typeCombo2.addItem("");
+    typeCombo2.setEnabled(false);
+    secondary.add(typeCombo2);
+    secondary.add(new JLabel(MessageUtils.getLocalizedMessage("search_sort.label.order")));
+    orderCombo2.setPreferredSize(new Dimension(100, 30));
+    orderCombo2.setEnabled(false);
+    secondary.add(orderCombo2);
+    panel.add(secondary);
+
+    JPanel clear = new JPanel(new FlowLayout(FlowLayout.LEADING));
+    clear.setOpaque(false);
+    JButton clearBtn = new JButton(MessageUtils.getLocalizedMessage("button.clear"));
+    clearBtn.addActionListener(listeners::clear);
+    clear.add(clearBtn);
+    panel.add(clear);
+
+    return panel;
+  }
+
+  @Override
+  public void setSearchModel(Search model) {
+    searchModel = model;
+  }
+
+  @Override
+  public void setSortableFields(Collection<String> sortableFields) {
+    fieldCombo1.removeAllItems();
+    fieldCombo2.removeAllItems();
+
+    fieldCombo1.addItem("");
+    fieldCombo2.addItem("");
+
+    for (String field : sortableFields) {
+      fieldCombo1.addItem(field);
+      fieldCombo2.addItem(field);
+    }
+  }
+
+  @Override
+  public Sort getSort() {
+    if (StringUtils.isNullOrEmpty((String) fieldCombo1.getSelectedItem())
+        && StringUtils.isNullOrEmpty((String) fieldCombo2.getSelectedItem())) {
+      return null;
+    }
+
+    List<SortField> li = new ArrayList<>();
+    if (!StringUtils.isNullOrEmpty((String) fieldCombo1.getSelectedItem())) {
+      searchModel.getSortType((String) fieldCombo1.getSelectedItem(), (String) typeCombo1.getSelectedItem(), isReverse(orderCombo1)).ifPresent(li::add);
+    }
+    if (!StringUtils.isNullOrEmpty((String) fieldCombo2.getSelectedItem())) {
+      searchModel.getSortType((String) fieldCombo2.getSelectedItem(), (String) typeCombo2.getSelectedItem(), isReverse(orderCombo2)).ifPresent(li::add);
+    }
+    return new Sort(li.toArray(new SortField[0]));
+  }
+
+  private boolean isReverse(JComboBox<String> order) {
+    return Order.valueOf((String) order.getSelectedItem()) == Order.DESC;
+  }
+
+  private class ListenerFunctions {
+
+    void changeField(ActionEvent e) {
+      if (e.getActionCommand().equalsIgnoreCase(COMMAND_FIELD_COMBO1)) {
+        resetField(fieldCombo1, typeCombo1, orderCombo1);
+      } else if (e.getActionCommand().equalsIgnoreCase(COMMAND_FIELD_COMBO2)) {
+        resetField(fieldCombo2, typeCombo2, orderCombo2);
+      }
+      resetExactHitsCnt();
+    }
+
+    private void resetField(JComboBox<String> fieldCombo, JComboBox<String> typeCombo, JComboBox<String> orderCombo) {
+      typeCombo.removeAllItems();
+      if (StringUtils.isNullOrEmpty((String) fieldCombo.getSelectedItem())) {
+        typeCombo.addItem("");
+        typeCombo.setEnabled(false);
+        orderCombo.setEnabled(false);
+      } else {
+        List<SortField> sortFields = searchModel.guessSortTypes((String) fieldCombo.getSelectedItem());
+        sortFields.stream()
+            .map(sf -> {
+              if (sf instanceof SortedNumericSortField) {
+                return ((SortedNumericSortField) sf).getNumericType().name();
+              } else {
+                return sf.getType().name();
+              }
+            }).forEach(typeCombo::addItem);
+        typeCombo.setEnabled(true);
+        orderCombo.setEnabled(true);
+      }
+    }
+
+    void clear(ActionEvent e) {
+      fieldCombo1.setSelectedIndex(0);
+      typeCombo1.removeAllItems();
+      typeCombo1.setSelectedItem("");
+      typeCombo1.setEnabled(false);
+      orderCombo1.setSelectedIndex(0);
+      orderCombo1.setEnabled(false);
+
+      fieldCombo2.setSelectedIndex(0);
+      typeCombo2.removeAllItems();
+      typeCombo2.setSelectedItem("");
+      typeCombo2.setEnabled(false);
+      orderCombo2.setSelectedIndex(0);
+      orderCombo2.setEnabled(false);
+
+      resetExactHitsCnt();
+    }
+
+    private void resetExactHitsCnt() {
+      operatorRegistry.get(SearchTabOperator.class).ifPresent(operator -> {
+        if (StringUtils.isNullOrEmpty((String) fieldCombo1.getSelectedItem()) &&
+            StringUtils.isNullOrEmpty((String) fieldCombo2.getSelectedItem())) {
+          operator.enableExactHitsCB(true);
+          operator.setExactHits(false);
+        } else {
+          operator.enableExactHitsCB(false);
+          operator.setExactHits(true);
+        }
+      });
+    }
+  }
+
+  enum Order {
+    ASC, DESC;
+
+    static String[] names() {
+      return Arrays.stream(values()).map(Order::name).toArray(String[]::new);
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortTabOperator.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortTabOperator.java
new file mode 100644
index 00000000000..bdaa027cc60
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/SortTabOperator.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
+
+import java.util.Collection;
+
+import org.apache.lucene.luke.app.desktop.components.ComponentOperatorRegistry;
+import org.apache.lucene.luke.models.search.Search;
+import org.apache.lucene.search.Sort;
+
+/** Operator for the Sort tab */
+public interface SortTabOperator extends ComponentOperatorRegistry.ComponentOperator {
+  void setSearchModel(Search model);
+
+  void setSortableFields(Collection<String> sortableFields);
+
+  Sort getSort();
+}
+
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/package-info.java
new file mode 100644
index 00000000000..dfa87f59cb4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/fragments/search/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** UI parts embedded in tabs */
+package org.apache.lucene.luke.app.desktop.components.fragments.search;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/package-info.java
new file mode 100644
index 00000000000..fefd0c88925
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** UI components of the desktop Luke */
+package org.apache.lucene.luke.app.desktop.components;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/NewField.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/NewField.java
new file mode 100644
index 00000000000..44162a07d80
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/NewField.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.dto.documents;
+
+import java.util.Objects;
+
+import org.apache.lucene.document.DoublePoint;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.document.FloatPoint;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedDocValuesField;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.IndexableFieldType;
+import org.apache.lucene.luke.app.desktop.util.NumericUtils;
+
+/** Data holder for a new field. This is used in the add document dialog. */
+public final class NewField {
+
+  private boolean deleted;
+
+  private String name;
+
+  private Class<? extends IndexableField> type;
+
+  private String value;
+
+  private IndexableFieldType fieldType;
+
+  private boolean stored;
+
+  public static NewField newInstance() {
+    NewField f = new NewField();
+    f.deleted = false;
+    f.name = "";
+    f.type = TextField.class;
+    f.value = "";
+    f.fieldType = new TextField("", "", Field.Store.NO).fieldType();
+    f.stored = f.fieldType.stored();
+    return f;
+  }
+
+  private NewField() {
+  }
+
+  public boolean isDeleted() {
+    return deleted;
+  }
+
+  public boolean deletedProperty() {
+    return deleted;
+  }
+
+  public void setDeleted(boolean value) {
+    deleted = value;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = Objects.requireNonNull(name);
+  }
+
+  public Class<? extends IndexableField> getTypeProperty() {
+    return type;
+  }
+
+  public Class<? extends IndexableField> getType() {
+    return type;
+  }
+
+  public void setType(Class<? extends IndexableField> type) {
+    this.type = Objects.requireNonNull(type);
+  }
+
+  public void resetFieldType(Class<?> type) {
+    if (type.equals(TextField.class)) {
+      fieldType = new TextField("", "", Field.Store.NO).fieldType();
+    } else if (type.equals(StringField.class)) {
+      fieldType = new StringField("", "", Field.Store.NO).fieldType();
+    } else if (type.equals(IntPoint.class)) {
+      fieldType = new IntPoint("", NumericUtils.convertToIntArray(value, true)).fieldType();
+    } else if (type.equals(LongPoint.class)) {
+      fieldType = new LongPoint("", NumericUtils.convertToLongArray(value, true)).fieldType();
+    } else if (type.equals(FloatPoint.class)) {
+      fieldType = new FloatPoint("", NumericUtils.convertToFloatArray(value, true)).fieldType();
+    } else if (type.equals(DoublePoint.class)) {
+      fieldType = new DoublePoint("", NumericUtils.convertToDoubleArray(value, true)).fieldType();
+    } else if (type.equals(SortedDocValuesField.class)) {
+      fieldType = new SortedDocValuesField("", null).fieldType();
+    } else if (type.equals(SortedSetDocValuesField.class)) {
+      fieldType = new SortedSetDocValuesField("", null).fieldType();
+    } else if (type.equals(NumericDocValuesField.class)) {
+      fieldType = new NumericDocValuesField("", 0).fieldType();
+    } else if (type.equals(SortedNumericDocValuesField.class)) {
+      fieldType = new SortedNumericDocValuesField("", 0).fieldType();
+    } else if (type.equals(StoredField.class)) {
+      fieldType = new StoredField("", "").fieldType();
+    } else if (type.equals(Field.class)) {
+      fieldType = new FieldType(this.fieldType);
+    }
+  }
+
+  public IndexableFieldType getFieldType() {
+    return fieldType;
+  }
+
+  public boolean isStored() {
+    return stored;
+  }
+
+  public void setStored(boolean stored) {
+    this.stored = stored;
+  }
+
+  public String getValue() {
+    return value;
+  }
+
+  public void setValue(String value) {
+    this.value = Objects.requireNonNull(value);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/package-info.java
new file mode 100644
index 00000000000..0f08238ddd1
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/dto/documents/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** DTO classes */
+package org.apache.lucene.luke.app.desktop.dto.documents;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/package-info.java
new file mode 100644
index 00000000000..c4c36bd22a9
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Views (UIs) for Luke */
+package org.apache.lucene.luke.app.desktop;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/DialogOpener.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/DialogOpener.java
new file mode 100644
index 00000000000..49e1b4f6c59
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/DialogOpener.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JDialog;
+import java.awt.Window;
+import java.util.function.Consumer;
+
+import org.apache.lucene.luke.app.desktop.LukeMain;
+
+/** An utility class for opening a dialog */
+public class DialogOpener<T extends DialogOpener.DialogFactory> {
+
+  private final T factory;
+
+  public DialogOpener(T factory) {
+    this.factory = factory;
+  }
+
+  public void open(String title, int width, int height, Consumer<? super T> initializer,
+                   String... styleSheets) {
+    open(LukeMain.getOwnerFrame(), title, width, height, initializer, styleSheets);
+  }
+
+  public void open(Window owner, String title, int width, int height, Consumer<? super T> initializer,
+                   String... styleSheets) {
+    initializer.accept(factory);
+    JDialog dialog = factory.create(owner, title, width, height);
+    dialog.setVisible(true);
+  }
+
+  /** factory interface to create a dialog */
+  public interface DialogFactory {
+    JDialog create(Window owner, String title, int width, int height);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ExceptionHandler.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ExceptionHandler.java
new file mode 100644
index 00000000000..b989748f52a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ExceptionHandler.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import java.lang.invoke.MethodHandles;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.app.desktop.MessageBroker;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+/** An utility class for handling exception */
+public final class ExceptionHandler {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  public static void handle(Throwable t, MessageBroker messageBroker) {
+    if (t instanceof LukeException) {
+      Throwable cause = t.getCause();
+      String message = (cause == null) ? t.getMessage() : t.getMessage() + " " + cause.getMessage();
+      log.warn(t.getMessage(), t);
+      messageBroker.showStatusMessage(message);
+    } else {
+      log.error(t.getMessage(), t);
+      messageBroker.showUnknownErrorMessage();
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/FontUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/FontUtils.java
new file mode 100644
index 00000000000..c4f47588815
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/FontUtils.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JLabel;
+import java.awt.Font;
+import java.awt.FontFormatException;
+import java.awt.font.TextAttribute;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+
+/** Font utilities */
+public class FontUtils {
+
+  public static final String TTF_RESOURCE_NAME = "org/apache/lucene/luke/app/desktop/font/ElegantIcons.ttf";
+
+  @SuppressWarnings("unchecked")
+  public static JLabel toLinkText(JLabel label) {
+    label.setForeground(StyleConstants.LINK_COLOR);
+    Font font = label.getFont();
+    Map<TextAttribute, Object> attributes = (Map<TextAttribute, Object>) font.getAttributes();
+    attributes.put(TextAttribute.UNDERLINE, TextAttribute.UNDERLINE_ON);
+    label.setFont(font.deriveFont(attributes));
+    return label;
+  }
+
+  public static Font createElegantIconFont() throws IOException, FontFormatException {
+    InputStream is = FontUtils.class.getClassLoader().getResourceAsStream(TTF_RESOURCE_NAME);
+    return Font.createFont(Font.TRUETYPE_FONT, is);
+  }
+
+  /**
+   * Generates HTML text with embedded Elegant Icon Font.
+   * See: https://www.elegantthemes.com/blog/resources/elegant-icon-font
+   *
+   * @param iconRef HTML numeric character reference of the icon
+   */
+  public static String elegantIconHtml(String iconRef) {
+    return "<html><font face=\"ElegantIcons\">" + iconRef + "</font></html>";
+  }
+
+  /**
+   * Generates HTML text with embedded Elegant Icon Font.
+   *
+   * @param iconRef HTML numeric character reference of the icon
+   * @param text - HTML text
+   */
+  public static String elegantIconHtml(String iconRef, String text) {
+    return "<html><font face=\"ElegantIcons\">" + iconRef + "</font>&nbsp;" + text + "</html>";
+  }
+
+  private FontUtils() {
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/HelpHeaderRenderer.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/HelpHeaderRenderer.java
new file mode 100644
index 00000000000..41c7f079e50
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/HelpHeaderRenderer.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JComponent;
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTable;
+import javax.swing.UIManager;
+import javax.swing.table.JTableHeader;
+import javax.swing.table.TableCellRenderer;
+import java.awt.Component;
+import java.awt.FlowLayout;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.util.Objects;
+
+import org.apache.lucene.luke.app.desktop.components.dialog.HelpDialogFactory;
+
+/**
+ * Cell render class for table header with help dialog.
+ */
+public final class HelpHeaderRenderer implements TableCellRenderer {
+
+  private JTable table;
+
+  private final JPanel panel = new JPanel();
+
+  private final JComponent helpContent;
+
+  private final HelpDialogFactory helpDialogFactory;
+
+  private final String title;
+
+  private final String desc;
+
+  private final JDialog parent;
+
+  public HelpHeaderRenderer(String title, String desc, JComponent helpContent, HelpDialogFactory helpDialogFactory) {
+    this(title, desc, helpContent, helpDialogFactory, null);
+  }
+
+  public HelpHeaderRenderer(String title, String desc, JComponent helpContent, HelpDialogFactory helpDialogFactory,
+                            JDialog parent) {
+    this.title = title;
+    this.desc = desc;
+    this.helpContent = helpContent;
+    this.helpDialogFactory = helpDialogFactory;
+    this.parent = parent;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+    if (table != null && this.table != table) {
+      this.table = table;
+      final JTableHeader header = table.getTableHeader();
+      if (header != null) {
+        panel.setLayout(new FlowLayout(FlowLayout.LEADING));
+        panel.setBorder(UIManager.getBorder("TableHeader.cellBorder"));
+        panel.add(new JLabel(value.toString()));
+
+        // add label with mouse click listener
+        // when the label is clicked, help dialog will be displayed.
+        JLabel helpLabel = new JLabel(FontUtils.elegantIconHtml("&#x74;", MessageUtils.getLocalizedMessage("label.help")));
+        helpLabel.setHorizontalAlignment(JLabel.LEFT);
+        helpLabel.setIconTextGap(5);
+        panel.add(FontUtils.toLinkText(helpLabel));
+
+        // add mouse listener to JTableHeader object.
+        // see: https://stackoverflow.com/questions/7137786/how-can-i-put-a-control-in-the-jtableheader-of-a-jtable
+        header.addMouseListener(new HelpClickListener(column));
+      }
+    }
+    return panel;
+  }
+
+  class HelpClickListener extends MouseAdapter {
+
+    int column;
+
+    HelpClickListener(int column) {
+      this.column = column;
+    }
+
+    @Override
+    public void mouseClicked(MouseEvent e) {
+      showPopupIfNeeded(e);
+    }
+
+    private void showPopupIfNeeded(MouseEvent e) {
+      JTableHeader header = (JTableHeader) e.getSource();
+      int column = header.getTable().columnAtPoint(e.getPoint());
+      if (column == this.column && e.getClickCount() == 1 && column != -1) {
+        // only when the targeted column header is clicked, pop up the dialog
+        if (Objects.nonNull(parent)) {
+          new DialogOpener<>(helpDialogFactory).open(parent, title, 600, 350,
+              (factory) -> {
+                factory.setDesc(desc);
+                factory.setContent(helpContent);
+              });
+        } else {
+          new DialogOpener<>(helpDialogFactory).open(title, 600, 350,
+              (factory) -> {
+                factory.setDesc(desc);
+                factory.setContent(helpContent);
+              });
+        }
+      }
+    }
+
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ImageUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ImageUtils.java
new file mode 100644
index 00000000000..d7989f9353e
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ImageUtils.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.ImageIcon;
+import java.awt.Image;
+
+/** Image utilities */
+public class ImageUtils {
+
+  private static final String IMAGE_BASE_DIR = "org/apache/lucene/luke/app/desktop/img/";
+
+  public static ImageIcon createImageIcon(String name, int width, int height) {
+    return createImageIcon(name, "", width, height);
+  }
+
+  public static ImageIcon createImageIcon(String name, String description, int width, int height) {
+    java.net.URL imgURL = ImageUtils.class.getClassLoader().getResource(IMAGE_BASE_DIR + name);
+    if (imgURL != null) {
+      ImageIcon originalIcon = new ImageIcon(imgURL, description);
+      ImageIcon icon = new ImageIcon(originalIcon.getImage().getScaledInstance(width, height, Image.SCALE_DEFAULT));
+      return icon;
+    } else {
+      return null;
+    }
+  }
+
+  private ImageUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ListUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ListUtils.java
new file mode 100644
index 00000000000..cc756eaffa3
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/ListUtils.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JList;
+import javax.swing.ListModel;
+import java.util.List;
+import java.util.function.IntFunction;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/** List model utilities */
+public class ListUtils {
+
+  public static <T> List<T> getAllItems(JList<T> jlist) {
+    ListModel<T> model = jlist.getModel();
+    return getAllItems(jlist, model::getElementAt);
+  }
+
+  public static <T, R> List<R> getAllItems(JList<T> jlist, IntFunction<R> mapFunc) {
+    ListModel<T> model = jlist.getModel();
+    return IntStream.range(0, model.getSize()).mapToObj(mapFunc).collect(Collectors.toList());
+  }
+
+  private ListUtils() {
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/MessageUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/MessageUtils.java
new file mode 100644
index 00000000000..cc6989159c9
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/MessageUtils.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+
+/**
+ * Utilities for accessing message resources.
+ */
+public class MessageUtils {
+
+  public static final String MESSAGE_BUNDLE_BASENAME = "org/apache/lucene/luke/app/desktop/messages/messages";
+
+  public static String getLocalizedMessage(String key) {
+    return bundle.getString(key);
+  }
+
+  public static String getLocalizedMessage(String key, Object... args) {
+    String pattern = bundle.getString(key);
+    return new MessageFormat(pattern, Locale.ENGLISH).format(args);
+  }
+
+  // https://stackoverflow.com/questions/4659929/how-to-use-utf-8-in-resource-properties-with-resourcebundle
+  private static ResourceBundle.Control UTF8_RESOURCEBUNDLE_CONTROL = new ResourceBundle.Control() {
+    @Override
+    public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException {
+      String bundleName = toBundleName(baseName, locale);
+      String resourceName = toResourceName(bundleName, "properties");
+      try (InputStream is = loader.getResourceAsStream(resourceName)) {
+        return new PropertyResourceBundle(new InputStreamReader(is, StandardCharsets.UTF_8));
+      }
+    }
+  };
+
+  private static ResourceBundle bundle = ResourceBundle.getBundle(MESSAGE_BUNDLE_BASENAME, Locale.ENGLISH, UTF8_RESOURCEBUNDLE_CONTROL);
+
+  private MessageUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/NumericUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/NumericUtils.java
new file mode 100644
index 00000000000..ae2ef5ac341
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/NumericUtils.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import java.util.Arrays;
+
+/** Utilities for handling numeric values */
+public class NumericUtils {
+
+  public static int[] convertToIntArray(String value, boolean ignoreException) throws NumberFormatException {
+    if (StringUtils.isNullOrEmpty(value)) {
+      return new int[]{0};
+    }
+    try {
+      return Arrays.stream(value.trim().split(",")).mapToInt(Integer::parseInt).toArray();
+    } catch (NumberFormatException e) {
+      if (ignoreException) {
+        return new int[]{0};
+      } else {
+        throw e;
+      }
+    }
+  }
+
+  public static long[] convertToLongArray(String value, boolean ignoreException) throws NumberFormatException {
+    if (StringUtils.isNullOrEmpty(value)) {
+      return new long[]{0};
+    }
+    try {
+      return Arrays.stream(value.trim().split(",")).mapToLong(Long::parseLong).toArray();
+    } catch (NumberFormatException e) {
+      if (ignoreException) {
+        return new long[]{0};
+      } else {
+        throw e;
+      }
+    }
+  }
+
+  public static float[] convertToFloatArray(String value, boolean ignoreException) throws NumberFormatException {
+    if (StringUtils.isNullOrEmpty(value)) {
+      return new float[]{0};
+    }
+    try {
+      String[] strVals = value.trim().split(",");
+      float[] values = new float[strVals.length];
+      for (int i = 0; i < strVals.length; i++) {
+        values[i] = Float.parseFloat(strVals[i]);
+      }
+      return values;
+    } catch (NumberFormatException e) {
+      if (ignoreException) {
+        return new float[]{0};
+      } else {
+        throw e;
+      }
+    }
+  }
+
+  public static double[] convertToDoubleArray(String value, boolean ignoreException) throws NumberFormatException {
+    if (StringUtils.isNullOrEmpty(value)) {
+      return new double[]{0};
+    }
+    try {
+      return Arrays.stream(value.trim().split(",")).mapToDouble(Double::parseDouble).toArray();
+    } catch (NumberFormatException e) {
+      if (ignoreException) {
+        return new double[]{0};
+      } else {
+        throw e;
+      }
+    }
+  }
+
+  public static long tryConvertToLongValue(String value) throws NumberFormatException {
+    try {
+      // try parse to long
+      return Long.parseLong(value.trim());
+    } catch (NumberFormatException e) {
+      // try parse to double
+      double dvalue = Double.parseDouble(value.trim());
+      return org.apache.lucene.util.NumericUtils.doubleToSortableLong(dvalue);
+    }
+  }
+
+  private NumericUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StringUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StringUtils.java
new file mode 100644
index 00000000000..23a4f79c2d4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StringUtils.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import java.util.Objects;
+
+/** Utilities for handling strings */
+public class StringUtils {
+
+  public static boolean isNullOrEmpty(String s) {
+    return Objects.isNull(s) || s.equals("");
+  }
+
+  private StringUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StyleConstants.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StyleConstants.java
new file mode 100644
index 00000000000..3b70265cf87
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/StyleConstants.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import java.awt.Color;
+import java.awt.Font;
+
+/** Constants for the default styles */
+public class StyleConstants {
+
+  public static Font FONT_BUTTON_LARGE = new Font("SanSerif", Font.PLAIN, 15);
+
+  public static Font FONT_MONOSPACE_LARGE = new Font("monospaced", Font.PLAIN, 12);
+
+  public static Color LINK_COLOR = Color.decode("#0099ff");
+
+  public static Color DISABLED_COLOR = Color.decode("#d9d9d9");
+
+  public static int TABLE_ROW_HEIGHT_DEFAULT = 18;
+
+  public static int TABLE_COLUMN_MARGIN_DEFAULT = 10;
+
+  public static int TABLE_ROW_MARGIN_DEFAULT = 3;
+
+  private StyleConstants() {
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TabUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TabUtils.java
new file mode 100644
index 00000000000..c3dc7a1e479
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TabUtils.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JTabbedPane;
+import javax.swing.UIManager;
+import java.awt.Graphics;
+
+/** Tab utilities */
+public class TabUtils {
+
+  public static void forceTransparent(JTabbedPane tabbedPane) {
+    String lookAndFeelClassName = UIManager.getLookAndFeel().getClass().getName();
+    if (lookAndFeelClassName.contains("AquaLookAndFeel")) {
+      // may be running on mac OS. nothing to do.
+      return;
+    }
+    // https://coderanch.com/t/600541/java/JtabbedPane-transparency
+    tabbedPane.setUI(new javax.swing.plaf.metal.MetalTabbedPaneUI() {
+      protected void paintContentBorder(Graphics g, int tabPlacement, int selectedIndex) {
+      }
+    });
+  }
+
+  private TabUtils(){}
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TableUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TableUtils.java
new file mode 100644
index 00000000000..cea72aea1fd
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TableUtils.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JTable;
+import javax.swing.table.DefaultTableModel;
+import javax.swing.table.TableModel;
+import java.awt.Color;
+import java.awt.event.MouseListener;
+import java.util.Arrays;
+import java.util.TreeMap;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.luke.app.desktop.components.TableColumnInfo;
+
+/** Table utilities */
+public class TableUtils {
+
+  public static void setupTable(JTable table, int selectionModel, TableModel model, MouseListener mouseListener,
+                                int... colWidth) {
+    table.setFillsViewportHeight(true);
+    table.setFont(StyleConstants.FONT_MONOSPACE_LARGE);
+    table.setRowHeight(StyleConstants.TABLE_ROW_HEIGHT_DEFAULT);
+    table.setShowHorizontalLines(true);
+    table.setShowVerticalLines(false);
+    table.setGridColor(Color.lightGray);
+    table.getColumnModel().setColumnMargin(StyleConstants.TABLE_COLUMN_MARGIN_DEFAULT);
+    table.setRowMargin(StyleConstants.TABLE_ROW_MARGIN_DEFAULT);
+    table.setSelectionMode(selectionModel);
+    if (model != null) {
+      table.setModel(model);
+    } else {
+      table.setModel(new DefaultTableModel());
+    }
+    if (mouseListener != null) {
+      table.removeMouseListener(mouseListener);
+      table.addMouseListener(mouseListener);
+    }
+    for (int i = 0; i < colWidth.length; i++) {
+      table.getColumnModel().getColumn(i).setMinWidth(colWidth[i]);
+      table.getColumnModel().getColumn(i).setMaxWidth(colWidth[i]);
+    }
+  }
+
+  public static void setEnabled(JTable table, boolean enabled) {
+    table.setEnabled(enabled);
+    if (enabled) {
+      table.setRowSelectionAllowed(true);
+      table.setForeground(Color.black);
+      table.setBackground(Color.white);
+    } else {
+      table.setRowSelectionAllowed(false);
+      table.setForeground(Color.gray);
+      table.setBackground(Color.lightGray);
+    }
+  }
+
+  public static <T extends TableColumnInfo> String[] columnNames(T[] columns) {
+    return columnMap(columns).entrySet().stream().map(e -> e.getValue().getColName()).toArray(String[]::new);
+  }
+
+  public static <T extends TableColumnInfo> TreeMap<Integer, T> columnMap(T[] columns) {
+    return Arrays.stream(columns).collect(Collectors.toMap(T::getIndex, UnaryOperator.identity(), (e1, e2) -> e1, TreeMap::new));
+  }
+
+  private TableUtils() {
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaAppender.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaAppender.java
new file mode 100644
index 00000000000..b7b1d421383
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaAppender.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JTextArea;
+import javax.swing.SwingUtilities;
+import java.io.Serializable;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.Core;
+import org.apache.logging.log4j.core.Filter;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.StringLayout;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.appender.AbstractOutputStreamAppender;
+import org.apache.logging.log4j.core.config.Property;
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
+
+/** Log appender for text areas */
+@Plugin(name = "TextArea", category = Core.CATEGORY_NAME, elementType = Appender.ELEMENT_TYPE, printObject = true)
+public final class TextAreaAppender extends AbstractAppender {
+
+  private static JTextArea textArea;
+
+  private static final ReadWriteLock rwLock = new ReentrantReadWriteLock();
+  private static final Lock readLock = rwLock.readLock();
+  private static final Lock writeLock = rwLock.writeLock();
+
+  protected TextAreaAppender(String name, Filter filter,
+                             org.apache.logging.log4j.core.Layout<? extends Serializable> layout, final boolean ignoreExceptions) {
+    super(name, filter, layout, ignoreExceptions, Property.EMPTY_ARRAY);
+  }
+
+  public static void setTextArea(JTextArea ta) {
+    writeLock.lock();
+    try {
+      if (textArea != null) {
+        throw new IllegalStateException("TextArea already set.");
+      }
+      textArea = ta;
+    } finally {
+      writeLock.unlock();
+    }
+  }
+
+  @Override
+  public void append(LogEvent event) {
+    readLock.lock();
+    try {
+      if (textArea == null) {
+        // just ignore any events logged before the area is available
+        return;
+      }
+  
+      final String message = ((StringLayout) getLayout()).toSerializable(event);
+      SwingUtilities.invokeLater(() -> {
+        textArea.append(message);
+      });
+    } finally {
+      readLock.unlock();
+    }
+  }
+
+  /**
+   * Builds TextAreaAppender instances.
+   *
+   * @param <B> The type to build
+   */
+  public static class Builder<B extends Builder<B>> extends AbstractOutputStreamAppender.Builder<B>
+      implements org.apache.logging.log4j.core.util.Builder<TextAreaAppender> {
+
+    @Override
+    public TextAreaAppender build() {
+      return new TextAreaAppender(getName(), getFilter(), getOrCreateLayout(), true);
+    }
+  }
+
+  @PluginBuilderFactory
+  public static <B extends Builder<B>> B newBuilder() {
+    return new Builder<B>().asBuilder();
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaPrintStream.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaPrintStream.java
new file mode 100644
index 00000000000..7c1f7caad2c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/TextAreaPrintStream.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JTextArea;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+
+/** PrintStream for text areas */
+public final class TextAreaPrintStream extends PrintStream {
+
+  private final ByteArrayOutputStream baos;
+
+  private final JTextArea textArea;
+
+  public TextAreaPrintStream(JTextArea textArea) throws UnsupportedEncodingException {
+    super(new ByteArrayOutputStream(), false, StandardCharsets.UTF_8.name()); // TODO: replace by Charset in Java 11
+    this.baos = (ByteArrayOutputStream) out;
+    this.textArea = textArea;
+    baos.reset();
+  }
+
+  @Override
+  public void flush() {
+    try {
+      textArea.append(baos.toString(StandardCharsets.UTF_8.name())); // TODO: replace by Charset in Java 11
+    } catch (UnsupportedEncodingException e) {
+      setError();
+    } finally {
+      baos.reset();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/URLLabel.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/URLLabel.java
new file mode 100644
index 00000000000..4b6e71bf0fe
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/URLLabel.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util;
+
+import javax.swing.JLabel;
+import java.awt.Cursor;
+import java.awt.Desktop;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+import org.apache.lucene.luke.models.LukeException;
+
+/** JLabel extension for representing urls */
+public final class URLLabel extends JLabel {
+
+  private final URL link;
+
+  public URLLabel(String text) {
+    super(text);
+
+    try {
+      this.link = new URL(text);
+    } catch (MalformedURLException e) {
+      throw new LukeException(e.getMessage(), e);
+    }
+
+    setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+
+    addMouseListener(new MouseAdapter() {
+      @Override
+      public void mouseClicked(MouseEvent e) {
+        openUrl(link);
+      }
+    });
+  }
+
+  private void openUrl(URL link) {
+    if (Desktop.isDesktopSupported()) {
+      try {
+        Desktop.getDesktop().browse(link.toURI());
+      } catch (IOException | URISyntaxException e) {
+        throw new LukeException(e.getMessage(), e);
+      }
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFile.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFile.java
new file mode 100644
index 00000000000..fd723ba78b7
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFile.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/** Interface representing ini files */
+public interface IniFile {
+
+  void load(Path path) throws IOException;
+
+  void store(Path path) throws IOException;
+
+  void put(String section, String option, Object value);
+
+  String getString(String section, String option);
+
+  Boolean getBoolean(String section, String option);
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileReader.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileReader.java
new file mode 100644
index 00000000000..21bb85ada49
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileReader.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+/** ini files interface */
+public interface IniFileReader {
+
+  Map<String, OptionMap> readSections(Path path) throws IOException;
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileWriter.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileWriter.java
new file mode 100644
index 00000000000..9977046e3a7
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/IniFileWriter.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+/** ini files writer */
+public interface IniFileWriter {
+
+  void writeSections(Path path, Map<String, OptionMap> sections) throws IOException;
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/OptionMap.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/OptionMap.java
new file mode 100644
index 00000000000..f7783d70609
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/OptionMap.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.util.LinkedHashMap;
+
+/** Key-value store for options */
+public class OptionMap extends LinkedHashMap<String, String> {
+
+  String getAsString(String key) {
+    return get(key);
+  }
+
+  Boolean getAsBoolean(String key) {
+    return Boolean.parseBoolean(get(key));
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFile.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFile.java
new file mode 100644
index 00000000000..3c539f81f7c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFile.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/** Simple implementation of {@link IniFile} */
+public class SimpleIniFile implements IniFile {
+
+  private final Map<String, OptionMap> sections = new LinkedHashMap<>();
+
+  private IniFileWriter writer = new SimpleIniFileWriter();
+
+  private IniFileReader reader = new SimpleIniFileReader();
+
+  @Override
+  public synchronized void load(Path path) throws IOException {
+    sections.putAll(reader.readSections(path));
+  }
+
+  @Override
+  public synchronized void store(Path path) throws IOException {
+    writer.writeSections(path, sections);
+  }
+
+  @Override
+  public synchronized void put(String section, String option, Object value) {
+    if (checkString(section) && checkString(option) && Objects.nonNull(value)) {
+      sections.putIfAbsent(section, new OptionMap());
+      sections.get(section).put(option, (value instanceof String) ? (String) value : String.valueOf(value));
+    }
+  }
+
+  @Override
+  public String getString(String section, String option) {
+    if (checkString(section) && checkString(option)) {
+      OptionMap options = sections.get(section);
+      if (options != null) {
+        return options.getAsString(option);
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public Boolean getBoolean(String section, String option) {
+    if (checkString(section) && checkString(option)) {
+      OptionMap options = sections.get(section);
+      if (options != null) {
+        return options.getAsBoolean(option);
+      }
+    }
+    return false;
+  }
+
+  private boolean checkString(String s) {
+    return Objects.nonNull(s) && !s.equals("");
+  }
+
+  Map<String, OptionMap> getSections() {
+    return sections;
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileReader.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileReader.java
new file mode 100644
index 00000000000..00a03636040
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileReader.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Simple implementation of {@link IniFileReader} */
+public class SimpleIniFileReader implements IniFileReader {
+
+  private String currentSection = "";
+
+  @Override
+  public Map<String, OptionMap> readSections(Path path) throws IOException {
+    final Map<String, OptionMap> sections = new LinkedHashMap<>();
+
+    try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
+      r.lines().forEach(line -> {
+        line = line.trim();
+
+        if (isSectionLine(line)) {
+          // set section if this is a valid section string
+          currentSection = line.substring(1, line.length()-1);
+          sections.putIfAbsent(currentSection, new OptionMap());
+        } else if (!currentSection.equals("")) {
+          // put option if this is a valid option string
+          String[] ary = line.split("=", 2);
+          if (ary.length == 2 && !ary[0].trim().equals("") && !ary[1].trim().equals("")) {
+            sections.get(currentSection).put(ary[0].trim(), ary[1].trim());
+          }
+        }
+
+      });
+    }
+    return sections;
+  }
+
+  private boolean isSectionLine(String line) {
+    return line.startsWith("[") && line.endsWith("]")
+        && line.substring(1, line.length()-1).matches("^[a-zA-Z0-9]+$");
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileWriter.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileWriter.java
new file mode 100644
index 00000000000..ae03bf635c4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileWriter.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+/** Simple implementation of {@link IniFileWriter} */
+public class SimpleIniFileWriter implements IniFileWriter {
+
+  @Override
+  public void writeSections(Path path, Map<String, OptionMap> sections) throws IOException {
+    try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
+      for (Map.Entry<String, OptionMap> section : sections.entrySet()) {
+        w.write("[" + section.getKey() + "]");
+        w.newLine();
+
+        for (Map.Entry<String, String> option : section.getValue().entrySet()) {
+          w.write(option.getKey() + " = " + option.getValue());
+          w.newLine();
+        }
+
+        w.newLine();
+      }
+      w.flush();
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/package-info.java
new file mode 100644
index 00000000000..d03b86fa42f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/inifile/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Ini file parser / writer */
+package org.apache.lucene.luke.app.desktop.util.inifile;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/Callable.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/Callable.java
new file mode 100644
index 00000000000..f5ddf2fee88
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/Callable.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.lang;
+
+/** Functional interface which provides sole method call() */
+@FunctionalInterface
+public interface Callable {
+  void call();
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/package-info.java
new file mode 100644
index 00000000000..5cf30577ae9
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/lang/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Syntax sugars / helpers */
+package org.apache.lucene.luke.app.desktop.util.lang;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/package-info.java
new file mode 100644
index 00000000000..bd43e1e5f96
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/desktop/util/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Utilities for the UI components */
+package org.apache.lucene.luke.app.desktop.util;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/app/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/app/package-info.java
new file mode 100644
index 00000000000..8e7ea9e4f4d
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/app/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Views (UIs) for Luke */
+package org.apache.lucene.luke.app;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/LukeException.java b/lucene/luke/src/java/org/apache/lucene/luke/models/LukeException.java
new file mode 100644
index 00000000000..d8bcbfa34ae
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/LukeException.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models;
+
+/** Wrapper exception class to convert checked exceptions to runtime exceptions. */
+public class LukeException extends RuntimeException {
+
+  public LukeException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public LukeException(Throwable cause) {
+    super(cause);
+  }
+
+  public LukeException(String message) {
+    super(message);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/LukeModel.java b/lucene/luke/src/java/org/apache/lucene/luke/models/LukeModel.java
new file mode 100644
index 00000000000..524426cfc5a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/LukeModel.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Objects;
+
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexCommit;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.Bits;
+
+/**
+ * Abstract model class. It holds index reader object and provides basic features for all concrete sub classes.
+ */
+public abstract class LukeModel {
+
+  protected Directory dir;
+
+  protected IndexReader reader;
+
+  protected Bits liveDocs;
+
+  protected IndexCommit commit;
+
+  protected LukeModel(IndexReader reader) {
+    this.reader = Objects.requireNonNull(reader);
+
+    if (reader instanceof DirectoryReader) {
+      DirectoryReader dr = (DirectoryReader) reader;
+      this.dir = dr.directory();
+      try {
+        this.commit = dr.getIndexCommit();
+      } catch (IOException e) {
+        throw new LukeException(e.getMessage(), e);
+      }
+    } else {
+      this.dir = null;
+      this.commit = null;
+    }
+
+    this.liveDocs = IndexUtils.getLiveDocs(reader);
+  }
+
+  protected LukeModel (Directory dir) {
+    this.dir = Objects.requireNonNull(dir);
+  }
+
+  public Collection<String> getFieldNames() {
+    return IndexUtils.getFieldNames(reader);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/Analysis.java b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/Analysis.java
new file mode 100644
index 00000000000..8b640ee2dd0
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/Analysis.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.analysis;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.util.CharFilterFactory;
+import org.apache.lucene.analysis.util.TokenFilterFactory;
+import org.apache.lucene.analysis.util.TokenizerFactory;
+import org.apache.lucene.luke.models.LukeException;
+
+/**
+ * A dedicated interface for Luke's Analysis tab.
+ */
+public interface Analysis {
+
+  /**
+   * Holder for a token.
+   */
+  class Token {
+    private final String term;
+    private final List<TokenAttribute> attributes;
+
+    Token(String term, List<TokenAttribute> attributes) {
+      this.term = Objects.requireNonNull(term);
+      this.attributes = Objects.requireNonNull(attributes);
+    }
+
+    /**
+     * Returns the string representation of this token.
+     */
+    public String getTerm() {
+      return term;
+    }
+
+    /**
+     * Returns attributes of this token.
+     */
+    public List<TokenAttribute> getAttributes() {
+      return Collections.unmodifiableList(attributes);
+    }
+  }
+
+  /**
+   * Holder for a token attribute.
+   */
+  class TokenAttribute {
+    private final String attClass;
+    private final Map<String, String> attValues;
+
+    TokenAttribute(String attClass, Map<String, String> attValues) {
+      this.attClass = Objects.requireNonNull(attClass);
+      this.attValues = Objects.requireNonNull(attValues);
+    }
+
+    /**
+     * Returns attribute class name.
+     */
+    public String getAttClass() {
+      return attClass;
+    }
+
+    /**
+     * Returns value of this attribute.
+     */
+    public Map<String, String> getAttValues() {
+      return Collections.unmodifiableMap(attValues);
+    }
+  }
+
+  /**
+   * Returns built-in {@link Analyzer}s.
+   */
+  Collection<Class<? extends Analyzer>> getPresetAnalyzerTypes();
+
+  /**
+   * Returns available char filter names.
+   */
+  Collection<String> getAvailableCharFilters();
+
+  /**
+   * Returns available tokenizer names.
+   */
+  Collection<String> getAvailableTokenizers();
+
+  /**
+   * Returns available token filter names.
+   */
+  Collection<String> getAvailableTokenFilters();
+
+  /**
+   * Creates new Analyzer instance for the specified class name.
+   *
+   * @param analyzerType - instantiable class name of an Analyzer
+   * @return new Analyzer instance
+   * @throws LukeException - if failed to create new Analyzer instance
+   */
+  Analyzer createAnalyzerFromClassName(String analyzerType);
+
+  /**
+   * Creates new custom Analyzer instance with the given configurations.
+   *
+   * @param config - custom analyzer configurations
+   * @return new Analyzer instance
+   * @throws LukeException - if failed to create new Analyzer instance
+   */
+  Analyzer buildCustomAnalyzer(CustomAnalyzerConfig config);
+
+  /**
+   * Analyzes given text with the current Analyzer.
+   *
+   * @param text - text string to analyze
+   * @return the list of token
+   * @throws LukeException - if an internal error occurs when analyzing text
+   */
+  List<Token> analyze(String text);
+
+  /**
+   * Returns current analyzer.
+   * @throws LukeException - if current analyzer not set
+   */
+  Analyzer currentAnalyzer();
+
+  /**
+   * Adds external jar files to classpath and loads custom {@link CharFilterFactory}s, {@link TokenizerFactory}s, or {@link TokenFilterFactory}s.
+   *
+   * @param jarFiles - list of paths to jar file
+   * @throws LukeException - if an internal error occurs when loading jars
+   */
+  void addExternalJars(List<String> jarFiles);
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisFactory.java
new file mode 100644
index 00000000000..8fa49c6162c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisFactory.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.analysis;
+
+/** Factory of {@link Analysis} */
+public class AnalysisFactory {
+
+  public Analysis newInstance() {
+    return new AnalysisImpl();
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisImpl.java
new file mode 100644
index 00000000000..7d76b8f32a8
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/AnalysisImpl.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.analysis;
+
+import java.io.IOException;
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.TokenStream;
+import org.apache.lucene.analysis.custom.CustomAnalyzer;
+import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
+import org.apache.lucene.analysis.util.CharFilterFactory;
+import org.apache.lucene.analysis.util.TokenFilterFactory;
+import org.apache.lucene.analysis.util.TokenizerFactory;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.util.reflection.ClassScanner;
+import org.apache.lucene.util.AttributeImpl;
+
+/** Default implementation of {@link AnalysisImpl} */
+public final class AnalysisImpl implements Analysis {
+
+  private List<Class<? extends Analyzer>> presetAnalyzerTypes;
+
+  private Analyzer analyzer;
+
+  @Override
+  public void addExternalJars(List<String> jarFiles) {
+    List<URL> urls = new ArrayList<>();
+
+    for (String jarFile : jarFiles) {
+      Path path = FileSystems.getDefault().getPath(jarFile);
+      if (!Files.exists(path) || !jarFile.endsWith(".jar")) {
+        throw new LukeException(String.format(Locale.ENGLISH, "Invalid jar file path: %s", jarFile));
+      }
+      try {
+        URL url = path.toUri().toURL();
+        urls.add(url);
+      } catch (IOException e) {
+        throw new LukeException(e.getMessage(), e);
+      }
+    }
+
+    // reload available tokenizers, charfilters, and tokenfilters
+    URLClassLoader classLoader = new URLClassLoader(
+        urls.toArray(new URL[0]), this.getClass().getClassLoader());
+    CharFilterFactory.reloadCharFilters(classLoader);
+    TokenizerFactory.reloadTokenizers(classLoader);
+    TokenFilterFactory.reloadTokenFilters(classLoader);
+  }
+
+  @Override
+  public Collection<Class<? extends Analyzer>> getPresetAnalyzerTypes() {
+    if (Objects.isNull(presetAnalyzerTypes)) {
+      List<Class<? extends Analyzer>> types = new ArrayList<>();
+      for (Class<? extends Analyzer> clazz : getInstantiableSubTypesBuiltIn(Analyzer.class)) {
+        try {
+          // add to presets if no args constructor is available
+          clazz.getConstructor();
+          types.add(clazz);
+        } catch (NoSuchMethodException e) {
+        }
+      }
+      presetAnalyzerTypes = Collections.unmodifiableList(types);
+    }
+    return presetAnalyzerTypes;
+  }
+
+  @Override
+  public Collection<String> getAvailableCharFilters() {
+    return CharFilterFactory.availableCharFilters().stream().sorted().collect(Collectors.toList());
+  }
+
+  @Override
+  public Collection<String> getAvailableTokenizers() {
+    return TokenizerFactory.availableTokenizers().stream().sorted().collect(Collectors.toList());
+  }
+
+  @Override
+  public Collection<String> getAvailableTokenFilters() {
+    return TokenFilterFactory.availableTokenFilters().stream().sorted().collect(Collectors.toList());
+  }
+
+  private <T> List<Class<? extends T>> getInstantiableSubTypesBuiltIn(Class<T> superType) {
+    ClassScanner scanner = new ClassScanner("org.apache.lucene.analysis", getClass().getClassLoader());
+    Set<Class<? extends T>> types = scanner.scanSubTypes(superType);
+    return types.stream()
+        .filter(type -> !Modifier.isAbstract(type.getModifiers()))
+        .filter(type -> !type.getSimpleName().startsWith("Mock"))
+        .sorted(Comparator.comparing(Class::getName))
+        .collect(Collectors.toList());
+  }
+
+  @Override
+  public List<Token> analyze(String text) {
+    Objects.requireNonNull(text);
+
+    if (analyzer == null) {
+      throw new LukeException("Analyzer is not set.");
+    }
+
+    try {
+      List<Token> result = new ArrayList<>();
+
+      TokenStream stream = analyzer.tokenStream("", text);
+      stream.reset();
+
+      CharTermAttribute charAtt = stream.getAttribute(CharTermAttribute.class);
+
+      // iterate tokens
+      while (stream.incrementToken()) {
+        List<TokenAttribute> attributes = new ArrayList<>();
+        Iterator<AttributeImpl> itr = stream.getAttributeImplsIterator();
+
+        while (itr.hasNext()) {
+          AttributeImpl att = itr.next();
+          Map<String, String> attValues = new LinkedHashMap<>();
+          att.reflectWith((attClass, key, value) -> {
+            if (value != null)
+              attValues.put(key, value.toString());
+          });
+          attributes.add(new TokenAttribute(att.getClass().getSimpleName(), attValues));
+        }
+
+        result.add(new Token(charAtt.toString(), attributes));
+      }
+      stream.close();
+
+      return result;
+    } catch (IOException e) {
+      throw new LukeException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public Analyzer createAnalyzerFromClassName(String analyzerType) {
+    Objects.requireNonNull(analyzerType);
+
+    try {
+      Class<? extends Analyzer> clazz = Class.forName(analyzerType).asSubclass(Analyzer.class);
+      this.analyzer = clazz.newInstance();
+      return analyzer;
+    } catch (ReflectiveOperationException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to instantiate class: %s", analyzerType), e);
+    }
+  }
+
+  @Override
+  public Analyzer buildCustomAnalyzer(CustomAnalyzerConfig config) {
+    Objects.requireNonNull(config);
+    try {
+      // create builder
+      CustomAnalyzer.Builder builder = config.getConfigDir()
+          .map(path -> CustomAnalyzer.builder(FileSystems.getDefault().getPath(path)))
+          .orElse(CustomAnalyzer.builder());
+
+      // set tokenizer
+      builder.withTokenizer(config.getTokenizerConfig().getName(), config.getTokenizerConfig().getParams());
+
+      // add char filters
+      for (CustomAnalyzerConfig.ComponentConfig cfConf : config.getCharFilterConfigs()) {
+        builder.addCharFilter(cfConf.getName(), cfConf.getParams());
+      }
+
+      // add token filters
+      for (CustomAnalyzerConfig.ComponentConfig tfConf : config.getTokenFilterConfigs()) {
+        builder.addTokenFilter(tfConf.getName(), tfConf.getParams());
+      }
+
+      // build analyzer
+      this.analyzer = builder.build();
+      return analyzer;
+    } catch (Exception e) {
+      throw new LukeException("Failed to build custom analyzer.", e);
+    }
+  }
+
+  @Override
+  public Analyzer currentAnalyzer() {
+    if (analyzer == null) {
+      throw new LukeException("Analyzer is not set.");
+    }
+    return analyzer;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/CustomAnalyzerConfig.java b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/CustomAnalyzerConfig.java
new file mode 100644
index 00000000000..1ffe2431852
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/CustomAnalyzerConfig.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.analysis;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Configurations for a custom analyzer.
+ */
+public final class CustomAnalyzerConfig {
+
+  private final String configDir;
+
+  private final ComponentConfig tokenizerConfig;
+
+  private final List<ComponentConfig> charFilterConfigs;
+
+  private final List<ComponentConfig> tokenFilterConfigs;
+
+  /** Builder for {@link CustomAnalyzerConfig} */
+  public static class Builder {
+    private String configDir;
+    private final ComponentConfig tokenizerConfig;
+    private final List<ComponentConfig> charFilterConfigs = new ArrayList<>();
+    private final List<ComponentConfig> tokenFilterConfigs = new ArrayList<>();
+
+    public Builder(String name, Map<String, String> tokenizerParams) {
+      Objects.requireNonNull(name);
+      Objects.requireNonNull(tokenizerParams);
+      tokenizerConfig = new ComponentConfig(name, new HashMap<>(tokenizerParams));
+    }
+
+    public Builder configDir(String val) {
+      configDir = val;
+      return this;
+    }
+
+    public Builder addCharFilterConfig(String name, Map<String, String> params) {
+      Objects.requireNonNull(name);
+      Objects.requireNonNull(params);
+      charFilterConfigs.add(new ComponentConfig(name, new HashMap<>(params)));
+      return this;
+    }
+
+    public Builder addTokenFilterConfig(String name, Map<String, String> params) {
+      Objects.requireNonNull(name);
+      Objects.requireNonNull(params);
+      tokenFilterConfigs.add(new ComponentConfig(name, new HashMap<>(params)));
+      return this;
+    }
+
+    public CustomAnalyzerConfig build() {
+      return new CustomAnalyzerConfig(this);
+    }
+  }
+
+  private CustomAnalyzerConfig(Builder builder) {
+    this.tokenizerConfig = builder.tokenizerConfig;
+    this.configDir = builder.configDir;
+    this.charFilterConfigs = builder.charFilterConfigs;
+    this.tokenFilterConfigs = builder.tokenFilterConfigs;
+  }
+
+  /**
+   * Returns directory path for configuration files, or empty.
+   */
+  Optional<String> getConfigDir() {
+    return Optional.ofNullable(configDir);
+  }
+
+  /**
+   * Returns Tokenizer configurations.
+   */
+  ComponentConfig getTokenizerConfig() {
+    return tokenizerConfig;
+  }
+
+  /**
+   * Returns CharFilters configurations.
+   */
+  List<ComponentConfig> getCharFilterConfigs() {
+    return Collections.unmodifiableList(charFilterConfigs);
+  }
+
+  /**
+   * Returns TokenFilters configurations.
+   */
+  List<ComponentConfig> getTokenFilterConfigs() {
+    return Collections.unmodifiableList(tokenFilterConfigs);
+  }
+
+  static class ComponentConfig {
+
+    /* SPI name */
+    private final String name;
+    /* parameter map */
+    private final Map<String, String> params;
+
+    ComponentConfig(String name, Map<String, String> params) {
+      this.name = Objects.requireNonNull(name);
+      this.params = Objects.requireNonNull(params);
+    }
+
+    String getName() {
+      return this.name;
+    }
+
+    Map<String, String> getParams() {
+      return this.params;
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/package-info.java
new file mode 100644
index 00000000000..52a9c0c087d
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/analysis/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for the Analysis tab */
+package org.apache.lucene.luke.models.analysis;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commit.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commit.java
new file mode 100644
index 00000000000..73f1594a11c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commit.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.io.IOException;
+
+import org.apache.lucene.index.IndexCommit;
+import org.apache.lucene.luke.models.util.IndexUtils;
+
+/**
+ * Holder for a commit.
+ */
+public final class Commit {
+
+  private long generation;
+
+  private boolean isDeleted;
+
+  private int segCount;
+
+  private String userData;
+
+  static Commit of(IndexCommit ic) {
+    Commit commit = new Commit();
+    commit.generation = ic.getGeneration();
+    commit.isDeleted = ic.isDeleted();
+    commit.segCount = ic.getSegmentCount();
+    try {
+      commit.userData = IndexUtils.getCommitUserData(ic);
+    } catch (IOException e) {
+    }
+    return commit;
+  }
+
+  public long getGeneration() {
+    return generation;
+  }
+
+  public boolean isDeleted() {
+    return isDeleted;
+  }
+
+  public int getSegCount() {
+    return segCount;
+  }
+
+  public String getUserData() {
+    return userData;
+  }
+
+  private Commit() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commits.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commits.java
new file mode 100644
index 00000000000..dbd8abe17cf
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Commits.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.lucene.codecs.Codec;
+import org.apache.lucene.luke.models.LukeException;
+
+/**
+ * A dedicated interface for Luke's Commits tab.
+ */
+public interface Commits {
+
+  /**
+   * Returns commits that exists in this Directory.
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<Commit> listCommits();
+
+  /**
+   * Returns a commit of the specified generation.
+   * @param commitGen - generation
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Commit> getCommit(long commitGen);
+
+  /**
+   * Returns index files for the specified generation.
+   * @param commitGen - generation
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<File> getFiles(long commitGen);
+
+  /**
+   * Returns segments for the specified generation.
+   * @param commitGen - generation
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<Segment> getSegments(long commitGen);
+
+  /**
+   * Returns internal codec attributes map for the specified segment.
+   * @param commitGen - generation
+   * @param name - segment name
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Map<String, String> getSegmentAttributes(long commitGen, String name);
+
+  /**
+   * Returns diagnotics for the specified segment.
+   * @param commitGen - generation
+   * @param name - segment name
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Map<String, String> getSegmentDiagnostics(long commitGen, String name);
+
+  /**
+   * Returns codec for the specified segment.
+   * @param commitGen - generation
+   * @param name - segment name
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Codec> getSegmentCodec(long commitGen, String name);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsFactory.java
new file mode 100644
index 00000000000..22d959d8621
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.store.Directory;
+
+/** Factory of {@link Commits} */
+public class CommitsFactory {
+
+  public Commits newInstance(Directory dir, String indexPath) {
+    return new CommitsImpl(dir, indexPath);
+  }
+
+  public Commits newInstance(DirectoryReader reader, String indexPath) {
+    return new CommitsImpl(reader, indexPath);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsImpl.java
new file mode 100644
index 00000000000..d29fecc0e33
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/CommitsImpl.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.codecs.Codec;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexCommit;
+import org.apache.lucene.index.SegmentInfos;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.LukeModel;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.store.Directory;
+
+/** Default implementation of {@link Commits} */
+public final class CommitsImpl extends LukeModel implements Commits {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final String indexPath;
+
+  private final Map<Long, IndexCommit> commitMap;
+
+  /**
+   * Constructs a CommitsImpl that holds given {@link Directory}.
+   *
+   * @param dir - the index directory
+   * @param indexPath - the path to index directory
+   */
+  public CommitsImpl(Directory dir, String indexPath) {
+    super(dir);
+    this.indexPath = indexPath;
+    this.commitMap = initCommitMap();
+  }
+
+  /**
+   * Constructs a CommitsImpl that holds the {@link Directory} wrapped in the given {@link DirectoryReader}.
+   *
+   * @param reader - the index reader
+   * @param indexPath - the path to index directory
+   */
+  public CommitsImpl(DirectoryReader reader, String indexPath) {
+    super(reader.directory());
+    this.indexPath = indexPath;
+    this.commitMap = initCommitMap();
+  }
+
+  private Map<Long, IndexCommit> initCommitMap() {
+    try {
+      List<IndexCommit> indexCommits = DirectoryReader.listCommits(dir);
+      Map<Long, IndexCommit> map = new TreeMap<>();
+      for (IndexCommit ic : indexCommits) {
+        map.put(ic.getGeneration(), ic);
+      }
+      return map;
+    } catch (IOException e) {
+      throw new LukeException("Failed to get commits list.", e);
+    }
+  }
+
+  @Override
+  public List<Commit> listCommits() throws LukeException {
+    List<Commit> commits = getCommitMap().values().stream()
+        .map(Commit::of)
+        .collect(Collectors.toList());
+    Collections.reverse(commits);
+    return commits;
+  }
+
+  @Override
+  public Optional<Commit> getCommit(long commitGen) throws LukeException {
+    IndexCommit ic = getCommitMap().get(commitGen);
+
+    if (ic == null) {
+      String msg = String.format(Locale.ENGLISH, "Commit generation %d not exists.", commitGen);
+      log.warn(msg);
+      return Optional.empty();
+    }
+
+    return Optional.of(Commit.of(ic));
+  }
+
+  @Override
+  public List<File> getFiles(long commitGen) throws LukeException {
+    IndexCommit ic = getCommitMap().get(commitGen);
+
+    if (ic == null) {
+      String msg = String.format(Locale.ENGLISH, "Commit generation %d not exists.", commitGen);
+      log.warn(msg);
+      return Collections.emptyList();
+    }
+
+    try {
+      return ic.getFileNames().stream()
+          .map(name -> File.of(indexPath, name))
+          .sorted(Comparator.comparing(File::getFileName))
+          .collect(Collectors.toList());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to load files for commit generation %d", commitGen), e);
+    }
+  }
+
+  @Override
+  public List<Segment> getSegments(long commitGen) throws LukeException {
+    try {
+      SegmentInfos infos = findSegmentInfos(commitGen);
+      if (infos == null) {
+        return Collections.emptyList();
+      }
+
+      return infos.asList().stream()
+          .map(Segment::of)
+          .sorted(Comparator.comparing(Segment::getName))
+          .collect(Collectors.toList());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to load segment infos for commit generation %d", commitGen), e);
+    }
+  }
+
+  @Override
+  public Map<String, String> getSegmentAttributes(long commitGen, String name) throws LukeException {
+    try {
+      SegmentInfos infos = findSegmentInfos(commitGen);
+      if (infos == null) {
+        return Collections.emptyMap();
+      }
+
+      return infos.asList().stream()
+          .filter(seg -> seg.info.name.equals(name))
+          .findAny()
+          .map(seg -> seg.info.getAttributes())
+          .orElse(Collections.emptyMap());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to load segment infos for commit generation %d", commitGen), e);
+    }
+  }
+
+  @Override
+  public Map<String, String> getSegmentDiagnostics(long commitGen, String name) throws LukeException {
+    try {
+      SegmentInfos infos = findSegmentInfos(commitGen);
+      if (infos == null) {
+        return Collections.emptyMap();
+      }
+
+      return infos.asList().stream()
+          .filter(seg -> seg.info.name.equals(name))
+          .findAny()
+          .map(seg -> seg.info.getDiagnostics())
+          .orElse(Collections.emptyMap());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to load segment infos for commit generation %d", commitGen), e);
+    }
+  }
+
+  @Override
+  public Optional<Codec> getSegmentCodec(long commitGen, String name) throws LukeException {
+    try {
+      SegmentInfos infos = findSegmentInfos(commitGen);
+      if (infos == null) {
+        return Optional.empty();
+      }
+
+      return infos.asList().stream()
+          .filter(seg -> seg.info.name.equals(name))
+          .findAny()
+          .map(seg -> seg.info.getCodec());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to load segment infos for commit generation %d", commitGen), e);
+    }
+  }
+
+  private Map<Long, IndexCommit> getCommitMap() throws LukeException {
+    if (dir == null) {
+      return Collections.emptyMap();
+    }
+    return Collections.unmodifiableMap(commitMap);
+  }
+
+  private SegmentInfos findSegmentInfos(long commitGen) throws LukeException, IOException {
+    IndexCommit ic = getCommitMap().get(commitGen);
+    if (ic == null) {
+      return null;
+    }
+    String segmentFile = ic.getSegmentsFileName();
+    return SegmentInfos.readCommit(dir, segmentFile);
+  }
+
+  static String toDisplaySize(long size) {
+    if (size < 1024) {
+      return String.valueOf(size) + " B";
+    } else if (size < 1048576) {
+      return String.valueOf(size / 1024) + " KB";
+    } else {
+      return String.valueOf(size / 1048576) + " MB";
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/File.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/File.java
new file mode 100644
index 00000000000..8038b39be3b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/File.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+/**
+ * Holder for a index file.
+ */
+public final class File {
+  private String fileName;
+  private String displaySize;
+
+  static File of(String indexPath, String name) {
+    File file = new File();
+    file.fileName = name;
+    try {
+      file.displaySize = CommitsImpl.toDisplaySize(Files.size(Paths.get(indexPath, name)));
+    } catch (IOException e) {
+      file.displaySize = "unknown";
+    }
+    return file;
+  }
+
+  public String getFileName() {
+    return fileName;
+  }
+
+  public String getDisplaySize() {
+    return displaySize;
+  }
+
+  private File() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Segment.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Segment.java
new file mode 100644
index 00000000000..cea86e2ec9f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/Segment.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.io.IOException;
+
+import org.apache.lucene.index.SegmentCommitInfo;
+
+/**
+ * Holder for a segment.
+ */
+public final class Segment {
+
+  private String name;
+
+  private int maxDoc;
+
+  private long delGen;
+
+  private int delCount;
+
+  private String luceneVer;
+
+  private String codecName;
+
+  private String displaySize;
+
+  private boolean useCompoundFile;
+
+  static Segment of(SegmentCommitInfo segInfo) {
+    Segment segment = new Segment();
+    segment.name = segInfo.info.name;
+    segment.maxDoc = segInfo.info.maxDoc();
+    segment.delGen = segInfo.getDelGen();
+    segment.delCount = segInfo.getDelCount();
+    segment.luceneVer = segInfo.info.getVersion().toString();
+    segment.codecName = segInfo.info.getCodec().getName();
+    try {
+      segment.displaySize = CommitsImpl.toDisplaySize(segInfo.sizeInBytes());
+    } catch (IOException e) {
+    }
+    segment.useCompoundFile = segInfo.info.getUseCompoundFile();
+    return segment;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public int getMaxDoc() {
+    return maxDoc;
+  }
+
+  public long getDelGen() {
+    return delGen;
+  }
+
+  public int getDelCount() {
+    return delCount;
+  }
+
+  public String getLuceneVer() {
+    return luceneVer;
+  }
+
+  public String getCodecName() {
+    return codecName;
+  }
+
+  public String getDisplaySize() {
+    return displaySize;
+  }
+
+  public boolean isUseCompoundFile() {
+    return useCompoundFile;
+  }
+
+  private Segment() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/commits/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/package-info.java
new file mode 100644
index 00000000000..87ed8a0158d
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/commits/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for the Commits tab */
+package org.apache.lucene.luke.models.commits;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValues.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValues.java
new file mode 100644
index 00000000000..ac1eff7e5ef
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValues.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * Holder for doc values.
+ */
+public final class DocValues {
+
+  private final DocValuesType dvType;
+
+  private final List<BytesRef> values;
+
+  private final List<Long> numericValues;
+
+  /**
+   * Returns a new doc values entry representing the specified doc values type and values.
+   * @param dvType - doc values type
+   * @param values - (string) values
+   * @param numericValues numeric values
+   * @return doc values
+   */
+  static DocValues of(DocValuesType dvType, List<BytesRef> values, List<Long> numericValues) {
+    return new DocValues(dvType, values, numericValues);
+  }
+
+  private DocValues(DocValuesType dvType, List<BytesRef> values, List<Long> numericValues) {
+    this.dvType = dvType;
+    this.values = values;
+    this.numericValues = numericValues;
+  }
+
+  /**
+   * Returns the type of this doc values.
+   */
+  public DocValuesType getDvType() {
+    return dvType;
+  }
+
+  /**
+   * Returns the list of (string) values.
+   */
+  public List<BytesRef> getValues() {
+    return values;
+  }
+
+  /**
+   * Returns the list of numeric values.
+   */
+  public List<Long> getNumericValues() {
+    return numericValues;
+  }
+
+  @Override
+  public String toString() {
+    String numValuesStr = numericValues.stream().map(String::valueOf).collect(Collectors.joining(","));
+    return "DocValues{" +
+        "dvType=" + dvType +
+        ", values=" + values +
+        ", numericValues=[" + numValuesStr + "]" +
+        '}';
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValuesAdapter.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValuesAdapter.java
new file mode 100644
index 00000000000..79a87e18099
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocValuesAdapter.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.lucene.index.BinaryDocValues;
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.SortedDocValues;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.index.SortedSetDocValues;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * An utility class to access to the doc values.
+ */
+final class DocValuesAdapter {
+
+  private final IndexReader reader;
+
+  DocValuesAdapter(IndexReader reader) {
+    this.reader = Objects.requireNonNull(reader);
+  }
+
+  /**
+   * Returns the doc values for the specified field in the specified document.
+   * Empty Optional instance is returned if no doc values is available for the field.
+   *
+   * @param docid - document id
+   * @param field - field name
+   * @return doc values, if exists, or empty
+   * @throws IOException - if there is a low level IO error.
+   */
+  Optional<DocValues> getDocValues(int docid, String field) throws IOException {
+    DocValuesType dvType = IndexUtils.getFieldInfo(reader, field).getDocValuesType();
+
+    switch (dvType) {
+      case BINARY:
+        return createBinaryDocValues(docid, field, DocValuesType.BINARY);
+      case NUMERIC:
+        return createNumericDocValues(docid, field, DocValuesType.NUMERIC);
+      case SORTED_NUMERIC:
+        return createSortedNumericDocValues(docid, field, DocValuesType.SORTED_NUMERIC);
+      case SORTED:
+        return createSortedDocValues(docid, field, DocValuesType.SORTED);
+      case SORTED_SET:
+        return createSortedSetDocValues(docid, field, DocValuesType.SORTED_SET);
+      default:
+        return Optional.empty();
+    }
+  }
+
+  private Optional<DocValues> createBinaryDocValues(int docid, String field, DocValuesType dvType)
+      throws IOException {
+    BinaryDocValues bvalues = IndexUtils.getBinaryDocValues(reader, field);
+
+    if (bvalues.advanceExact(docid)) {
+      DocValues dv = DocValues.of(
+          dvType,
+          Collections.singletonList(BytesRef.deepCopyOf(bvalues.binaryValue())),
+          Collections.emptyList());
+      return Optional.of(dv);
+    }
+
+    return Optional.empty();
+  }
+
+  private Optional<DocValues> createNumericDocValues(int docid, String field, DocValuesType dvType)
+      throws IOException{
+    NumericDocValues nvalues = IndexUtils.getNumericDocValues(reader, field);
+
+    if (nvalues.advanceExact(docid)) {
+      DocValues dv = DocValues.of(
+          dvType,
+          Collections.emptyList(),
+          Collections.singletonList(nvalues.longValue())
+      );
+      return Optional.of(dv);
+    }
+
+    return Optional.empty();
+  }
+
+  private Optional<DocValues> createSortedNumericDocValues(int docid, String field, DocValuesType dvType)
+      throws IOException {
+    SortedNumericDocValues snvalues = IndexUtils.getSortedNumericDocValues(reader, field);
+
+    if (snvalues.advanceExact(docid)) {
+      List<Long> numericValues = new ArrayList<>();
+
+      int dvCount = snvalues.docValueCount();
+      for (int i = 0; i < dvCount; i++) {
+        numericValues.add(snvalues.nextValue());
+      }
+
+      DocValues dv = DocValues.of(
+          dvType,
+          Collections.emptyList(),
+          numericValues
+      );
+      return Optional.of(dv);
+    }
+
+    return Optional.empty();
+  }
+
+  private Optional<DocValues> createSortedDocValues(int docid, String field, DocValuesType dvType)
+      throws IOException {
+    SortedDocValues svalues = IndexUtils.getSortedDocValues(reader, field);
+
+    if (svalues.advanceExact(docid)) {
+      DocValues dv = DocValues.of(
+          dvType,
+          Collections.singletonList(BytesRef.deepCopyOf(svalues.binaryValue())),
+          Collections.emptyList()
+      );
+      return Optional.of(dv);
+    }
+
+    return Optional.empty();
+  }
+
+  private Optional<DocValues> createSortedSetDocValues(int docid, String field, DocValuesType dvType)
+      throws IOException {
+    SortedSetDocValues ssvalues = IndexUtils.getSortedSetDocvalues(reader, field);
+
+    if (ssvalues.advanceExact(docid)) {
+      List<BytesRef> values = new ArrayList<>();
+
+      long ord;
+      while ((ord = ssvalues.nextOrd()) != SortedSetDocValues.NO_MORE_ORDS) {
+        values.add(BytesRef.deepCopyOf(ssvalues.lookupOrd(ord)));
+      }
+
+      DocValues dv = DocValues.of(
+          dvType,
+          values,
+          Collections.emptyList()
+      );
+      return Optional.of(dv);
+    }
+
+    return Optional.empty();
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentField.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentField.java
new file mode 100644
index 00000000000..5c18d3054e9
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentField.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.MultiDocValues;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * Holder for a document field's information and data.
+ */
+public final class DocumentField {
+
+  // field name
+  private String name;
+
+  // index options
+  private IndexOptions idxOptions;
+  private boolean hasTermVectors;
+  private boolean hasPayloads;
+  private boolean hasNorms;
+  private long norm;
+
+  // stored value
+  private boolean isStored;
+  private String stringValue;
+  private BytesRef binaryValue;
+  private Number numericValue;
+
+  // doc values
+  private DocValuesType dvType;
+
+  // point values
+  private int pointDimensionCount;
+  private int pointNumBytes;
+
+  static DocumentField of(FieldInfo finfo, IndexReader reader, int docId)
+      throws IOException {
+    return of(finfo, null, reader, docId);
+  }
+
+  static DocumentField of(FieldInfo finfo, IndexableField field, IndexReader reader, int docId)
+      throws IOException {
+
+    Objects.requireNonNull(finfo);
+    Objects.requireNonNull(reader);
+
+    DocumentField dfield = new DocumentField();
+
+    dfield.name = finfo.name;
+    dfield.idxOptions = finfo.getIndexOptions();
+    dfield.hasTermVectors = finfo.hasVectors();
+    dfield.hasPayloads = finfo.hasPayloads();
+    dfield.hasNorms = finfo.hasNorms();
+
+    if (finfo.hasNorms()) {
+      NumericDocValues norms = MultiDocValues.getNormValues(reader, finfo.name);
+      if (norms.advanceExact(docId)) {
+        dfield.norm = norms.longValue();
+      }
+    }
+
+    dfield.dvType = finfo.getDocValuesType();
+
+    dfield.pointDimensionCount = finfo.getPointDataDimensionCount();
+    dfield.pointNumBytes = finfo.getPointNumBytes();
+
+    if (field != null) {
+      dfield.isStored = field.fieldType().stored();
+      dfield.stringValue = field.stringValue();
+      if (field.binaryValue() != null) {
+        dfield.binaryValue = BytesRef.deepCopyOf(field.binaryValue());
+      }
+      dfield.numericValue = field.numericValue();
+    }
+
+    return dfield;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public IndexOptions getIdxOptions() {
+    return idxOptions;
+  }
+
+  public boolean hasTermVectors() {
+    return hasTermVectors;
+  }
+
+  public boolean hasPayloads() {
+    return hasPayloads;
+  }
+
+  public boolean hasNorms() {
+    return hasNorms;
+  }
+
+  public long getNorm() {
+    return norm;
+  }
+
+  public boolean isStored() {
+    return isStored;
+  }
+
+  public String getStringValue() {
+    return stringValue;
+  }
+
+  public BytesRef getBinaryValue() {
+    return binaryValue;
+  }
+
+  public Number getNumericValue() {
+    return numericValue;
+  }
+
+  public DocValuesType getDvType() {
+    return dvType;
+  }
+
+  public int getPointDimensionCount() {
+    return pointDimensionCount;
+  }
+
+  public int getPointNumBytes() {
+    return pointNumBytes;
+  }
+
+  @Override
+  public String toString() {
+    return "DocumentField{" +
+        "name='" + name + '\'' +
+        ", idxOptions=" + idxOptions +
+        ", hasTermVectors=" + hasTermVectors +
+        ", isStored=" + isStored +
+        ", dvType=" + dvType +
+        ", pointDimensionCount=" + pointDimensionCount +
+        '}';
+  }
+
+  private DocumentField() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/Documents.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/Documents.java
new file mode 100644
index 00000000000..d3735412e21
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/Documents.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.lucene.index.Term;
+import org.apache.lucene.luke.models.LukeException;
+
+/**
+ * A dedicated interface for Luke's Documents tab.
+ */
+public interface Documents {
+
+  /**
+   * Returns one greater than the largest possible document number.
+   */
+  int getMaxDoc();
+
+  /**
+   * Returns field names in this index.
+   */
+  Collection<String> getFieldNames();
+
+  /**
+   * Returns true if the document with the specified <code>docid</code> is not deleted, otherwise false.
+   * @param docid - document id
+   */
+  boolean isLive(int docid);
+
+  /**
+   * Returns the list of field information and field data for the specified document.
+   *
+   * @param docid - document id
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<DocumentField> getDocumentFields(int docid);
+
+  /**
+   * Returns the current target field name.
+   */
+  String getCurrentField();
+
+  /**
+   * Returns the first indexed term in the specified field.
+   * Empty Optional instance is returned if no terms are available for the field.
+   *
+   * @param field - field name
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Term> firstTerm(String field);
+
+  /**
+   * Increments the terms iterator and returns the next indexed term for the target field.
+   * Empty Optional instance is returned if the terms iterator has not been positioned yet, or has been exhausted.
+   *
+   * @return next term, if exists, or empty
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Term> nextTerm();
+
+  /**
+   * Seeks to the specified term, if it exists, or to the next (ceiling) term. Returns the term that was found.
+   * Empty Optional instance is returned if the terms iterator has not been positioned yet, or has been exhausted.
+   *
+   * @param termText - term to seek
+   * @return found term, if exists, or empty
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Term> seekTerm(String termText);
+
+  /**
+   * Returns the first document id (posting) associated with the current term.
+   * Empty Optional instance is returned if the terms iterator has not been positioned yet, or the postings iterator has been exhausted.
+   *
+   * @return document id, if exists, or empty
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Integer> firstTermDoc();
+
+  /**
+   * Increments the postings iterator and returns the next document id (posting) for the current term.
+   * Empty Optional instance is returned if the terms iterator has not been positioned yet, or the postings iterator has been exhausted.
+   *
+   * @return document id, if exists, or empty
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Integer> nextTermDoc();
+
+  /**
+   * Returns the list of the position information for the current posting.
+   *
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<TermPosting> getTermPositions();
+
+  /**
+   * Returns the document frequency for the current term (the number of documents containing the current term.)
+   * Empty Optional instance is returned if the terms iterator has not been positioned yet.
+   *
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<Integer> getDocFreq();
+
+  /**
+   * Returns the term vectors for the specified field in the specified document.
+   * If no term vector is available for the field, empty list is returned.
+   *
+   * @param docid - document id
+   * @param field - field name
+   * @return list of term vector elements
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<TermVectorEntry> getTermVectors(int docid, String field);
+
+  /**
+   * Returns the doc values for the specified field in the specified document.
+   * Empty Optional instance is returned if no doc values is available for the field.
+   *
+   * @param docid - document id
+   * @param field - field name
+   * @return doc values, if exists, or empty
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<DocValues> getDocValues(int docid, String field);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsFactory.java
new file mode 100644
index 00000000000..96b0a6fb6e9
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsFactory.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import org.apache.lucene.index.IndexReader;
+
+/** Factory of {@link Documents} */
+public class DocumentsFactory {
+
+  public Documents newInstance(IndexReader reader) {
+    return new DocumentsImpl(reader);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsImpl.java
new file mode 100644
index 00000000000..e4b25296fb4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/DocumentsImpl.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.PostingsEnum;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.index.Terms;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.LukeModel;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.luke.util.BytesRefUtils;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.util.BytesRef;
+
+/** Default implementation of {@link Documents} */
+public final class DocumentsImpl extends LukeModel implements Documents {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final TermVectorsAdapter tvAdapter;
+
+  private final DocValuesAdapter dvAdapter;
+
+  private String curField;
+
+  private TermsEnum tenum;
+
+  private PostingsEnum penum;
+
+  /**
+   * Constructs an DocumentsImpl that holds given {@link IndexReader}.
+   * @param reader - the index reader
+   */
+  public DocumentsImpl(IndexReader reader) {
+    super(reader);
+    this.tvAdapter = new TermVectorsAdapter(reader);
+    this.dvAdapter = new DocValuesAdapter(reader);
+  }
+
+  @Override
+  public int getMaxDoc() {
+    return reader.maxDoc();
+  }
+
+  @Override
+  public boolean isLive(int docid) {
+    return liveDocs == null || liveDocs.get(docid);
+  }
+
+  @Override
+  public List<DocumentField> getDocumentFields(int docid) {
+    if (!isLive(docid)) {
+      log.info("Doc #{} was deleted", docid);
+      return Collections.emptyList();
+    }
+
+    List<DocumentField> res = new ArrayList<>();
+
+    try {
+      Document doc = reader.document(docid);
+
+      for (FieldInfo finfo : IndexUtils.getFieldInfos(reader)) {
+        // iterate all fields for this document
+        IndexableField[] fields = doc.getFields(finfo.name);
+        if (fields.length == 0) {
+          // no stored data is available
+          res.add(DocumentField.of(finfo, reader, docid));
+        } else {
+          for (IndexableField field : fields) {
+            res.add(DocumentField.of(finfo, field, reader, docid));
+          }
+        }
+      }
+
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Fields information not available for doc %d.", docid), e);
+    }
+
+    return res;
+  }
+
+  @Override
+  public String getCurrentField() {
+    return curField;
+  }
+
+  @Override
+  public Optional<Term> firstTerm(String field) {
+    Objects.requireNonNull(field);
+
+    try {
+      Terms terms = IndexUtils.getTerms(reader, field);
+
+      if (terms == null) {
+        // no such field?
+        resetCurrentField();
+        resetTermsIterator();
+        log.warn("Terms not available for field: {}.", field);
+        return Optional.empty();
+      } else {
+        setCurrentField(field);
+        setTermsIterator(terms.iterator());
+
+        if (tenum.next() == null) {
+          // no term available for this field
+          resetTermsIterator();
+          log.warn("No term available for field: {}.", field);
+          return Optional.empty();
+        } else {
+          return Optional.of(new Term(curField, tenum.term()));
+        }
+      }
+
+    } catch (IOException e) {
+      resetTermsIterator();
+      throw new LukeException(String.format(Locale.ENGLISH, "Terms not available for field: %s.", field), e);
+    } finally {
+      // discard current postings enum
+      resetPostingsIterator();
+    }
+  }
+
+  @Override
+  public Optional<Term> nextTerm() {
+    if (tenum == null) {
+      // terms enum not initialized
+      log.warn("Terms enum un-positioned.");
+      return Optional.empty();
+    }
+
+    try {
+      if (tenum.next() == null) {
+        // end of the iterator
+        resetTermsIterator();
+        log.info("Reached the end of the term iterator for field: {}.", curField);
+        return Optional.empty();
+
+      } else {
+        return Optional.of(new Term(curField, tenum.term()));
+      }
+    } catch (IOException e) {
+      resetTermsIterator();
+      throw new LukeException(String.format(Locale.ENGLISH, "Terms not available for field: %s.", curField), e);
+    } finally {
+      // discard current postings enum
+      resetPostingsIterator();
+    }
+  }
+
+  @Override
+  public Optional<Term> seekTerm(String termText) {
+    Objects.requireNonNull(termText);
+
+    if (curField == null) {
+      // field is not selected
+      log.warn("Field not selected.");
+      return Optional.empty();
+    }
+
+    try {
+      Terms terms = IndexUtils.getTerms(reader, curField);
+      setTermsIterator(terms.iterator());
+
+      if (tenum.seekCeil(new BytesRef(termText)) == TermsEnum.SeekStatus.END) {
+        // reached to the end of the iterator
+        resetTermsIterator();
+        log.info("Reached the end of the term iterator for field: {}.", curField);
+        return Optional.empty();
+      } else {
+        return Optional.of(new Term(curField, tenum.term()));
+      }
+    } catch (IOException e) {
+      resetTermsIterator();
+      throw new LukeException(String.format(Locale.ENGLISH, "Terms not available for field: %s.", curField), e);
+    } finally {
+      // discard current postings enum
+      resetPostingsIterator();
+    }
+  }
+
+  @Override
+  public Optional<Integer> firstTermDoc() {
+    if (tenum == null) {
+      // terms enum is not set
+      log.warn("Terms enum un-positioned.");
+      return Optional.empty();
+    }
+
+    try {
+      setPostingsIterator(tenum.postings(penum, PostingsEnum.ALL));
+
+      if (penum.nextDoc() == PostingsEnum.NO_MORE_DOCS) {
+        // no docs available for this term
+        resetPostingsIterator();
+        log.warn("No docs available for term: {} in field: {}.", BytesRefUtils.decode(tenum.term()), curField);
+        return Optional.empty();
+      } else {
+        return Optional.of(penum.docID());
+      }
+    } catch (IOException e) {
+      resetPostingsIterator();
+      throw new LukeException(String.format(Locale.ENGLISH, "Term docs not available for field: %s.", curField), e);
+    }
+  }
+
+  @Override
+  public Optional<Integer> nextTermDoc() {
+    if (penum == null) {
+      // postings enum is not initialized
+      log.warn("Postings enum un-positioned for field: {}.", curField);
+      return Optional.empty();
+    }
+
+    try {
+      if (penum.nextDoc() == PostingsEnum.NO_MORE_DOCS) {
+        // end of the iterator
+        resetPostingsIterator();
+        log.info("Reached the end of the postings iterator for term: {} in field: {}", BytesRefUtils.decode(tenum.term()), curField);
+        return Optional.empty();
+      } else {
+        return Optional.of(penum.docID());
+      }
+    } catch (IOException e) {
+      resetPostingsIterator();
+      throw new LukeException(String.format(Locale.ENGLISH, "Term docs not available for field: %s.", curField), e);
+    }
+  }
+
+  @Override
+  public List<TermPosting> getTermPositions() {
+    if (penum == null) {
+      // postings enum is not initialized
+      log.warn("Postings enum un-positioned for field: {}.", curField);
+      return Collections.emptyList();
+    }
+
+    List<TermPosting> res = new ArrayList<>();
+
+    try {
+      int freq = penum.freq();
+
+      for (int i = 0; i < freq; i++) {
+        int position = penum.nextPosition();
+        if (position < 0) {
+          // no position information available
+          continue;
+        }
+        TermPosting posting = TermPosting.of(position, penum);
+        res.add(posting);
+      }
+
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Postings not available for field %s.", curField), e);
+    }
+
+    return res;
+  }
+
+
+  @Override
+  public Optional<Integer> getDocFreq() {
+    if (tenum == null) {
+      // terms enum is not initialized
+      log.warn("Terms enum un-positioned for field: {}.", curField);
+      return Optional.empty();
+    }
+
+    try {
+      return Optional.of(tenum.docFreq());
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH,"Doc frequency not available for field: %s.", curField), e);
+    }
+  }
+
+  @Override
+  public List<TermVectorEntry> getTermVectors(int docid, String field) {
+    try {
+      return tvAdapter.getTermVector(docid, field);
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Term vector not available for doc: #%d and field: %s", docid, field), e);
+    }
+  }
+
+  @Override
+  public Optional<DocValues> getDocValues(int docid, String field) {
+    try {
+      return dvAdapter.getDocValues(docid, field);
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Doc values not available for doc: #%d and field: %s", docid, field), e);
+    }
+  }
+
+  private void resetCurrentField() {
+    this.curField = null;
+  }
+
+  private void setCurrentField(String field) {
+    this.curField = field;
+  }
+
+  private void resetTermsIterator() {
+    this.tenum = null;
+  }
+
+  private void setTermsIterator(TermsEnum tenum) {
+    this.tenum = tenum;
+  }
+
+  private void resetPostingsIterator() {
+    this.penum = null;
+  }
+
+  private void setPostingsIterator(PostingsEnum penum) {
+    this.penum = penum;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermPosting.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermPosting.java
new file mode 100644
index 00000000000..84d7af1b264
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermPosting.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+
+import org.apache.lucene.index.PostingsEnum;
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * Holder for a term's position information, and optionally, offsets and payloads.
+ */
+public final class TermPosting {
+
+  // position
+  private int position = -1;
+
+  // start and end offset (optional)
+  private int startOffset = -1;
+  private int endOffset = -1;
+
+  // payload (optional)
+  private BytesRef payload;
+
+  static TermPosting of(int position, PostingsEnum penum) throws IOException {
+    TermPosting posting = new TermPosting();
+
+    // set position
+    posting.position = position;
+
+    // set offset (if available)
+    int sOffset = penum.startOffset();
+    int eOffset = penum.endOffset();
+    if (sOffset >= 0 && eOffset >= 0) {
+      posting.startOffset = sOffset;
+      posting.endOffset = eOffset;
+    }
+
+    // set payload (if available)
+    if (penum.getPayload() != null) {
+      posting.payload = BytesRef.deepCopyOf(penum.getPayload());
+    }
+
+    return posting;
+  }
+
+  public int getPosition() {
+    return position;
+  }
+
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  public int getEndOffset() {
+    return endOffset;
+  }
+
+  public BytesRef getPayload() {
+    return payload;
+  }
+
+  @Override
+  public String toString() {
+    return "TermPosting{" +
+        "position=" + position +
+        ", startOffset=" + startOffset +
+        ", endOffset=" + endOffset +
+        ", payload=" + payload +
+        '}';
+  }
+
+  private TermPosting() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorEntry.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorEntry.java
new file mode 100644
index 00000000000..643d299f1be
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorEntry.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.OptionalInt;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.index.PostingsEnum;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.luke.util.BytesRefUtils;
+
+/**
+ * Holder for term vector entry representing the term and their number of occurrences, and optionally, positions in the document field.
+ */
+public final class TermVectorEntry {
+
+  private final String termText;
+  private final long freq;
+  private final List<TermVectorPosition> positions;
+
+  /**
+   * Returns a new term vector entry representing the specified term, and optionally, positions.
+   *
+   * @param te - positioned terms iterator
+   * @return term vector entry
+   * @throws IOException - if there is a low level IO error.
+   */
+  static TermVectorEntry of(TermsEnum te) throws IOException {
+    Objects.requireNonNull(te);
+
+    String termText = BytesRefUtils.decode(te.term());
+
+    List<TermVectorEntry.TermVectorPosition> tvPositions = new ArrayList<>();
+    PostingsEnum pe = te.postings(null, PostingsEnum.OFFSETS);
+    pe.nextDoc();
+    int freq = pe.freq();
+    for (int i = 0; i < freq; i++) {
+      int pos = pe.nextPosition();
+      if (pos < 0) {
+        // no position information available
+        continue;
+      }
+      TermVectorPosition tvPos = TermVectorPosition.of(pos, pe);
+      tvPositions.add(tvPos);
+    }
+
+    return new TermVectorEntry(termText, te.totalTermFreq(), tvPositions);
+  }
+
+  private TermVectorEntry(String termText, long freq, List<TermVectorPosition> positions) {
+    this.termText = termText;
+    this.freq = freq;
+    this.positions = positions;
+  }
+
+  /**
+   * Returns the string representation for this term.
+   */
+  public String getTermText() {
+    return termText;
+  }
+
+  /**
+   * Returns the number of occurrences of this term in the document field.
+   */
+  public long getFreq() {
+    return freq;
+  }
+
+  /**
+   * Returns the list of positions for this term in the document field.
+   */
+  public List<TermVectorPosition> getPositions() {
+    return positions;
+  }
+
+  @Override
+  public String toString() {
+    String positionsStr = positions.stream()
+        .map(TermVectorPosition::toString)
+        .collect(Collectors.joining(","));
+
+    return "TermVectorEntry{" +
+        "termText='" + termText + '\'' +
+        ", freq=" + freq +
+        ", positions=" + positionsStr +
+        '}';
+  }
+
+  /**
+   * Holder for position information for a term vector entry.
+   */
+  public static final class TermVectorPosition {
+    private final int position;
+    private final int startOffset;
+    private final int endOffset;
+
+    /**
+     * Returns a new position entry representing the specified posting, and optionally, start and end offsets.
+     * @param pos - term position
+     * @param pe - positioned postings iterator
+     * @return position entry
+     * @throws IOException - if there is a low level IO error.
+     */
+    static TermVectorPosition of(int pos, PostingsEnum pe) throws IOException {
+      Objects.requireNonNull(pe);
+
+      int sOffset = pe.startOffset();
+      int eOffset = pe.endOffset();
+      if (sOffset >= 0 && eOffset >= 0) {
+        return new TermVectorPosition(pos, sOffset, eOffset);
+      }
+      return new TermVectorPosition(pos);
+    }
+
+    /**
+     * Returns the position for this term in the document field.
+     */
+    public int getPosition() {
+      return position;
+    }
+
+    /**
+     * Returns the start offset for this term in the document field.
+     * Empty Optional instance is returned if no offset information available.
+     */
+    public OptionalInt getStartOffset() {
+      return startOffset >= 0 ? OptionalInt.of(startOffset) : OptionalInt.empty();
+    }
+
+    /**
+     * Returns the end offset for this term in the document field.
+     * Empty Optional instance is returned if no offset information available.
+     */
+    public OptionalInt getEndOffset() {
+      return endOffset >= 0 ? OptionalInt.of(endOffset) : OptionalInt.empty();
+    }
+
+    @Override
+    public String toString() {
+      return "TermVectorPosition{" +
+          "position=" + position +
+          ", startOffset=" + startOffset +
+          ", endOffset=" + endOffset +
+          '}';
+    }
+
+    private TermVectorPosition(int position) {
+      this(position, -1, -1);
+    }
+
+    private TermVectorPosition(int position, int startOffset, int endOffset) {
+      this.position = position;
+      this.startOffset = startOffset;
+      this.endOffset = endOffset;
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorsAdapter.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorsAdapter.java
new file mode 100644
index 00000000000..accdf253d4b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/TermVectorsAdapter.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.Terms;
+import org.apache.lucene.index.TermsEnum;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+/**
+ * An utility class to access to the term vectors.
+ */
+final class TermVectorsAdapter {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private IndexReader reader;
+
+  TermVectorsAdapter(IndexReader reader) {
+    this.reader = Objects.requireNonNull(reader);
+  }
+
+  /**
+   * Returns the term vectors for the specified field in the specified document.
+   * If no term vector is available for the field, empty list is returned.
+   *
+   * @param docid - document id
+   * @param field - field name
+   * @return list of term vector elements
+   * @throws IOException - if there is a low level IO error.
+   */
+  List<TermVectorEntry> getTermVector(int docid, String field) throws IOException {
+    Terms termVector = reader.getTermVector(docid, field);
+    if (termVector == null) {
+      // no term vector available
+      log.warn("No term vector indexed for doc: #{} and field: {}", docid, field);
+      return Collections.emptyList();
+    }
+
+    List<TermVectorEntry> res = new ArrayList<>();
+    TermsEnum te = termVector.iterator();
+    while (te.next() != null) {
+      res.add(TermVectorEntry.of(te));
+    }
+    return res;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/documents/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/package-info.java
new file mode 100644
index 00000000000..6f4a38b753c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/documents/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for the Documents tab */
+package org.apache.lucene.luke.models.documents;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/Overview.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/Overview.java
new file mode 100644
index 00000000000..9913be368d2
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/Overview.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A dedicated interface for Luke's Overview tab.
+ */
+public interface Overview {
+
+  /**
+   * Returns the currently opened index directory path,
+   * or the root directory path if multiple index directories are opened.
+   */
+  String getIndexPath();
+
+  /**
+   * Returns the number of fields in this index.
+   */
+  int getNumFields();
+
+  /**
+   * Returns the number of documents in this index.
+   */
+  int getNumDocuments();
+
+  /**
+   * Returns the total number of terms in this index.
+   *
+   * @throws org.apache.lucene.luke.models.LukeException - if an internal error occurs when accessing index
+   */
+  long getNumTerms();
+
+  /**
+   * Returns true if this index includes deleted documents.
+   */
+  boolean hasDeletions();
+
+  /**
+   * Returns the number of deleted documents in this index.
+   */
+  int getNumDeletedDocs();
+
+  /**
+   * Returns true if the index is optimized.
+   * Empty Optional instance is returned if multiple indexes are opened.
+   */
+  Optional<Boolean> isOptimized();
+
+  /**
+   * Returns the version number when this index was opened.
+   * Empty Optional instance is returned if multiple indexes are opened.
+   */
+  Optional<Long> getIndexVersion();
+
+  /**
+   * Returns the string representation for the Lucene segment version when the index was created.
+   * Empty Optional instance is returned if multiple indexes are opened.
+   *
+   * @throws org.apache.lucene.luke.models.LukeException - if an internal error occurs when accessing index
+   */
+  Optional<String> getIndexFormat();
+
+  /**
+   * Returns the currently opened {@link org.apache.lucene.store.Directory} implementation class name.
+   * Empty Optional instance is returned if multiple indexes are opened.
+   */
+  Optional<String> getDirImpl();
+
+  /**
+   * Returns the information of the commit point that reader has opened.
+   *
+   * Empty Optional instance is returned if multiple indexes are opened.
+   */
+  Optional<String> getCommitDescription();
+
+  /**
+   * Returns the user provided data for the commit point.
+   * Empty Optional instance is returned if multiple indexes are opened.
+   *
+   * @throws org.apache.lucene.luke.models.LukeException - if an internal error occurs when accessing index
+   */
+  Optional<String> getCommitUserData();
+
+  /**
+   * Returns all fields with the number of terms for each field sorted by {@link TermCountsOrder}
+   *
+   * @param order - the sort order
+   * @return the ordered map of terms and their frequencies
+   * @throws org.apache.lucene.luke.models.LukeException - if an internal error occurs when accessing index
+   */
+  Map<String, Long> getSortedTermCounts(TermCountsOrder order);
+
+  /**
+   * Returns the top indexed terms with their statistics for the specified field.
+   *
+   * @param field - the field name
+   * @param numTerms - the max number of terms to be returned
+   * @return the list of top terms and their document frequencies
+   * @throws org.apache.lucene.luke.models.LukeException - if an internal error occurs when accessing index
+   */
+  List<TermStats> getTopTerms(String field, int numTerms);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewFactory.java
new file mode 100644
index 00000000000..620e2e51501
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewFactory.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import org.apache.lucene.index.IndexReader;
+
+/** Factory of {@link Overview} */
+public class OverviewFactory {
+
+  public Overview newInstance(IndexReader reader, String indexPath) {
+    return new OverviewImpl(reader, indexPath);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewImpl.java
new file mode 100644
index 00000000000..4dfd06be1e6
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/OverviewImpl.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.LukeModel;
+import org.apache.lucene.luke.models.util.IndexUtils;
+
+/** Default implementation of {@link Overview} */
+public final class OverviewImpl extends LukeModel implements Overview {
+
+  private final String indexPath;
+
+  private final TermCounts termCounts;
+
+  private final TopTerms topTerms;
+
+  /**
+   * Constructs an OverviewImpl that holds the given {@link IndexReader}.
+   *
+   * @param reader - the index reader
+   * @param indexPath - the (root) index directory path
+   * @throws LukeException - if an internal error is occurred when accessing index
+   */
+  public OverviewImpl(IndexReader reader, String indexPath) {
+    super(reader);
+    this.indexPath = Objects.requireNonNull(indexPath);
+    try {
+      this.termCounts = new TermCounts(reader);
+    } catch (IOException e) {
+      throw new LukeException("An error occurred when collecting term statistics.");
+    }
+    this.topTerms = new TopTerms(reader);
+  }
+
+  @Override
+  public String getIndexPath() {
+    return indexPath;
+  }
+
+  @Override
+  public int getNumFields() {
+    return IndexUtils.getFieldInfos(reader).size();
+  }
+
+  @Override
+  public int getNumDocuments() {
+    return reader.numDocs();
+  }
+
+  @Override
+  public long getNumTerms() {
+    return termCounts.numTerms();
+  }
+
+  @Override
+  public boolean hasDeletions() {
+    return reader.hasDeletions();
+  }
+
+  @Override
+  public int getNumDeletedDocs() {
+    return reader.numDeletedDocs();
+  }
+
+  @Override
+  public Optional<Boolean> isOptimized() {
+    if (commit != null) {
+      return Optional.of(commit.getSegmentCount() == 1);
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public Optional<Long> getIndexVersion() {
+    if (reader instanceof DirectoryReader) {
+      return Optional.of(((DirectoryReader) reader).getVersion());
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public Optional<String> getIndexFormat() {
+    if (dir == null) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(IndexUtils.getIndexFormat(dir));
+    } catch (IOException e) {
+      throw new LukeException("Index format not available.", e);
+    }
+  }
+
+  @Override
+  public Optional<String> getDirImpl() {
+    if (dir == null) {
+      return Optional.empty();
+    }
+    return Optional.of(dir.getClass().getName());
+  }
+
+  @Override
+  public Optional<String> getCommitDescription() {
+    if (commit == null) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        commit.getSegmentsFileName()
+            + " (generation=" + commit.getGeneration()
+            + ", segs=" + commit.getSegmentCount() + ")");
+  }
+
+  @Override
+  public Optional<String> getCommitUserData() {
+    if (commit == null) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(IndexUtils.getCommitUserData(commit));
+    } catch (IOException e) {
+      throw new LukeException("Commit user data not available.", e);
+    }
+  }
+
+  @Override
+  public Map<String, Long> getSortedTermCounts(TermCountsOrder order) {
+    if (order == null) {
+      order = TermCountsOrder.COUNT_DESC;
+    }
+    return termCounts.sortedTermCounts(order);
+  }
+
+  @Override
+  public List<TermStats> getTopTerms(String field, int numTerms) {
+    Objects.requireNonNull(field);
+
+    if (numTerms < 0) {
+      throw new IllegalArgumentException(String.format(Locale.ENGLISH, "'numTerms' must be a positive integer: %d is not accepted.", numTerms));
+    }
+    try {
+      return topTerms.getTopTerms(field, numTerms);
+    } catch (Exception e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Top terms for field %s not available.", field), e);
+    }
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCounts.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCounts.java
new file mode 100644
index 00000000000..d48edd79d1f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCounts.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.luke.models.util.IndexUtils;
+
+/**
+ * An utility class that collects term counts terms for all fields in a index.
+ */
+final class TermCounts {
+
+  private final Map<String, Long> termCountMap;
+
+  TermCounts(IndexReader reader) throws IOException {
+    Objects.requireNonNull(reader);
+    termCountMap = IndexUtils.countTerms(reader, IndexUtils.getFieldNames(reader));
+  }
+
+  /**
+   * Returns the total number of terms in this index.
+   */
+  long numTerms() {
+    return termCountMap.values().stream().mapToLong(Long::longValue).sum();
+  }
+
+  /**
+   * Returns all fields with the number of terms for each field sorted by {@link TermCountsOrder}
+   * @param order - sort order
+   */
+  Map<String, Long> sortedTermCounts(TermCountsOrder order){
+    Objects.requireNonNull(order);
+
+    Comparator<Map.Entry<String, Long>> comparator;
+    switch (order) {
+      case NAME_ASC:
+        comparator = Map.Entry.comparingByKey();
+        break;
+      case NAME_DESC:
+        comparator = Map.Entry.<String, Long>comparingByKey().reversed();
+        break;
+      case COUNT_ASC:
+        comparator = Map.Entry.comparingByValue();
+        break;
+      case COUNT_DESC:
+        comparator = Map.Entry.<String, Long>comparingByValue().reversed();
+        break;
+      default:
+        comparator = Map.Entry.comparingByKey();
+    }
+    return sortedTermCounts(comparator);
+  }
+
+  private Map<String, Long> sortedTermCounts(Comparator<Map.Entry<String, Long>> comparator) {
+    return termCountMap.entrySet().stream()
+        .sorted(comparator)
+        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (v1, v2) -> v1, LinkedHashMap::new));
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCountsOrder.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCountsOrder.java
new file mode 100644
index 00000000000..a5976ba8d52
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermCountsOrder.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+/**
+ * Sort orders for fields with their term counts
+ */
+public enum TermCountsOrder {
+  /**
+   * Ascending order by the field name
+   */
+  NAME_ASC,
+
+  /**
+   * Descending order by the field name
+   */
+  NAME_DESC,
+
+  /**
+   * Ascending order by the count of terms
+   */
+  COUNT_ASC,
+
+  /**
+   * Descending order by the count of terms
+   */
+  COUNT_DESC
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermStats.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermStats.java
new file mode 100644
index 00000000000..b97afe7c0ae
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TermStats.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import org.apache.lucene.luke.util.BytesRefUtils;
+
+/**
+ * Holder for statistics for a term in a specific field.
+ */
+public final class TermStats {
+
+  private final String decodedTermText;
+
+  private final String field;
+
+  private final int docFreq;
+
+  /**
+   * Returns a TermStats instance representing the specified {@link org.apache.lucene.misc.TermStats} value.
+   */
+  static TermStats of(org.apache.lucene.misc.TermStats stats) {
+    String termText = BytesRefUtils.decode(stats.termtext);
+    return new TermStats(termText, stats.field, stats.docFreq);
+  }
+
+  private TermStats(String decodedTermText, String field, int docFreq) {
+    this.decodedTermText = decodedTermText;
+    this.field = field;
+    this.docFreq = docFreq;
+  }
+
+  /**
+   * Returns the string representation for this term.
+   */
+  public String getDecodedTermText() {
+    return decodedTermText;
+  }
+
+  /**
+   * Returns the field name.
+   */
+  public String getField() {
+    return field;
+  }
+
+  /**
+   * Returns the document frequency of this term.
+   */
+  public int getDocFreq() {
+    return docFreq;
+  }
+
+  @Override
+  public String toString() {
+    return "TermStats{" +
+        "decodedTermText='" + decodedTermText + '\'' +
+        ", field='" + field + '\'' +
+        ", docFreq=" + docFreq +
+        '}';
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TopTerms.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TopTerms.java
new file mode 100644
index 00000000000..f1a00fe8d5c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/TopTerms.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.WeakHashMap;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.misc.HighFreqTerms;
+
+/**
+ * An utility class that collects terms and their statistics in a specific field.
+ */
+final class TopTerms {
+
+  private final IndexReader reader;
+
+  private final Map<String, List<TermStats>> topTermsCache;
+
+  TopTerms(IndexReader reader) {
+    this.reader = Objects.requireNonNull(reader);
+    this.topTermsCache = new WeakHashMap<>();
+  }
+
+  /**
+   * Returns the top indexed terms with their statistics for the specified field.
+   *
+   * @param field - the field name
+   * @param numTerms - the max number of terms to be returned
+   * @throws Exception - if an error occurs when collecting term statistics
+   */
+  List<TermStats> getTopTerms(String field, int numTerms) throws Exception {
+
+    if (!topTermsCache.containsKey(field) || topTermsCache.get(field).size() < numTerms) {
+      org.apache.lucene.misc.TermStats[] stats =
+          HighFreqTerms.getHighFreqTerms(reader, numTerms, field, new HighFreqTerms.DocFreqComparator());
+
+      List<TermStats> topTerms = Arrays.stream(stats)
+          .map(TermStats::of)
+          .collect(Collectors.toList());
+
+      // cache computed statistics for later uses
+      topTermsCache.put(field, topTerms);
+    }
+
+    return Collections.unmodifiableList(topTermsCache.get(field));
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/overview/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/package-info.java
new file mode 100644
index 00000000000..11b12e81266
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/overview/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for the Overview tab */
+package org.apache.lucene.luke.models.overview;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java
new file mode 100644
index 00000000000..0065130864b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and internal APIs for Luke */
+package org.apache.lucene.luke.models;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/MLTConfig.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/MLTConfig.java
new file mode 100644
index 00000000000..f4d77061a56
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/MLTConfig.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.lucene.queries.mlt.MoreLikeThis;
+
+/**
+ * Configurations for MoreLikeThis query.
+ */
+public final class MLTConfig {
+
+  private final List<String> fields;
+
+  private final int maxDocFreq;
+
+  private final int minDocFreq;
+
+  private final int minTermFreq;
+
+  /** Builder for {@link MLTConfig} */
+  public static class Builder {
+
+    private final List<String> fields = new ArrayList<>();
+    private int maxDocFreq = MoreLikeThis.DEFAULT_MAX_DOC_FREQ;
+    private int minDocFreq = MoreLikeThis.DEFAULT_MIN_DOC_FREQ;
+    private int minTermFreq = MoreLikeThis.DEFAULT_MIN_TERM_FREQ;
+
+    public Builder fields(Collection<String> val) {
+      fields.addAll(val);
+      return this;
+    }
+
+    public Builder maxDocFreq(int val) {
+      maxDocFreq = val;
+      return this;
+    }
+
+    public Builder minDocFreq(int val) {
+      minDocFreq = val;
+      return this;
+    }
+
+    public Builder minTermFreq(int val) {
+      minTermFreq = val;
+      return this;
+    }
+
+    public MLTConfig build() {
+      return new MLTConfig(this);
+    }
+  }
+
+  private MLTConfig(Builder builder) {
+    this.fields = Collections.unmodifiableList(builder.fields);
+    this.maxDocFreq = builder.maxDocFreq;
+    this.minDocFreq = builder.minDocFreq;
+    this.minTermFreq = builder.minTermFreq;
+  }
+
+  public String[] getFieldNames() {
+    return fields.toArray(new String[fields.size()]);
+  }
+
+  public int getMaxDocFreq() {
+    return maxDocFreq;
+  }
+
+  public int getMinDocFreq() {
+    return minDocFreq;
+  }
+
+  public int getMinTermFreq() {
+    return minTermFreq;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/QueryParserConfig.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/QueryParserConfig.java
new file mode 100644
index 00000000000..4e7d984f648
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/QueryParserConfig.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.document.DateTools;
+
+/**
+ * Configurations for query parser.
+ */
+public final class QueryParserConfig {
+
+  /** query operators */
+  public enum Operator {
+    AND, OR
+  }
+
+  private final boolean useClassicParser;
+
+  private final boolean enablePositionIncrements;
+
+  private final boolean allowLeadingWildcard;
+
+  private final DateTools.Resolution dateResolution;
+
+  private final Operator defaultOperator;
+
+  private final float fuzzyMinSim;
+
+  private final int fuzzyPrefixLength;
+
+  private final Locale locale;
+
+  private final TimeZone timeZone;
+
+  private final int phraseSlop;
+
+  // classic parser only configurations
+  private final boolean autoGenerateMultiTermSynonymsPhraseQuery;
+
+  private final boolean autoGeneratePhraseQueries;
+
+  private final boolean splitOnWhitespace;
+
+  // standard parser only configurations
+  private final Map<String, Class<? extends Number>> typeMap;
+
+  /** Builder for {@link QueryParserConfig} */
+  public static class Builder {
+    private boolean useClassicParser = true;
+    private boolean enablePositionIncrements = true;
+    private boolean allowLeadingWildcard = false;
+    private DateTools.Resolution dateResolution = DateTools.Resolution.MILLISECOND;
+    private Operator defaultOperator = Operator.OR;
+    private float fuzzyMinSim = 2f;
+    private int fuzzyPrefixLength = 0;
+    private Locale locale = Locale.getDefault();
+    private TimeZone timeZone = TimeZone.getDefault();
+    private int phraseSlop = 0;
+    private boolean autoGenerateMultiTermSynonymsPhraseQuery = false;
+    private boolean autoGeneratePhraseQueries = false;
+    private boolean splitOnWhitespace = false;
+    private Map<String, Class<? extends Number>> typeMap = new HashMap<>();
+
+    /** Builder for {@link QueryParserConfig} */
+    public Builder useClassicParser(boolean value) {
+      useClassicParser = value;
+      return this;
+    }
+
+    public Builder enablePositionIncrements(boolean value) {
+      enablePositionIncrements = value;
+      return this;
+    }
+
+    public Builder allowLeadingWildcard(boolean value) {
+      allowLeadingWildcard = value;
+      return this;
+    }
+
+    public Builder dateResolution(DateTools.Resolution value) {
+      dateResolution = value;
+      return this;
+    }
+
+    public Builder defaultOperator(Operator op) {
+      defaultOperator = op;
+      return this;
+    }
+
+    public Builder fuzzyMinSim(float val) {
+      fuzzyMinSim = val;
+      return this;
+    }
+
+    public Builder fuzzyPrefixLength(int val) {
+      fuzzyPrefixLength = val;
+      return this;
+    }
+
+    public Builder locale(Locale val) {
+      locale = val;
+      return this;
+    }
+
+    public Builder timeZone(TimeZone val) {
+      timeZone = val;
+      return this;
+    }
+
+    public Builder phraseSlop(int val) {
+      phraseSlop = val;
+      return this;
+    }
+
+    public Builder autoGenerateMultiTermSynonymsPhraseQuery(boolean val) {
+      autoGenerateMultiTermSynonymsPhraseQuery = val;
+      return this;
+    }
+
+    public Builder autoGeneratePhraseQueries(boolean val) {
+      autoGeneratePhraseQueries = val;
+      return this;
+    }
+
+    public Builder splitOnWhitespace(boolean val) {
+      splitOnWhitespace = val;
+      return this;
+    }
+
+    public Builder typeMap(Map<String, Class<? extends Number>> val) {
+      typeMap = val;
+      return this;
+    }
+
+    public QueryParserConfig build() {
+      return new QueryParserConfig(this);
+    }
+  }
+
+  private QueryParserConfig(Builder builder) {
+    this.useClassicParser = builder.useClassicParser;
+    this.enablePositionIncrements = builder.enablePositionIncrements;
+    this.allowLeadingWildcard = builder.allowLeadingWildcard;
+    this.dateResolution = builder.dateResolution;
+    this.defaultOperator = builder.defaultOperator;
+    this.fuzzyMinSim = builder.fuzzyMinSim;
+    this.fuzzyPrefixLength = builder.fuzzyPrefixLength;
+    this.locale = builder.locale;
+    this.timeZone = builder.timeZone;
+    this.phraseSlop = builder.phraseSlop;
+    this.autoGenerateMultiTermSynonymsPhraseQuery = builder.autoGenerateMultiTermSynonymsPhraseQuery;
+    this.autoGeneratePhraseQueries = builder.autoGeneratePhraseQueries;
+    this.splitOnWhitespace = builder.splitOnWhitespace;
+    this.typeMap = Collections.unmodifiableMap(builder.typeMap);
+  }
+
+  public boolean isUseClassicParser() {
+    return useClassicParser;
+  }
+
+  public boolean isAutoGenerateMultiTermSynonymsPhraseQuery() {
+    return autoGenerateMultiTermSynonymsPhraseQuery;
+  }
+
+  public boolean isEnablePositionIncrements() {
+    return enablePositionIncrements;
+  }
+
+  public boolean isAllowLeadingWildcard() {
+    return allowLeadingWildcard;
+  }
+
+  public boolean isAutoGeneratePhraseQueries() {
+    return autoGeneratePhraseQueries;
+  }
+
+  public boolean isSplitOnWhitespace() {
+    return splitOnWhitespace;
+  }
+
+  public DateTools.Resolution getDateResolution() {
+    return dateResolution;
+  }
+
+  public Operator getDefaultOperator() {
+    return defaultOperator;
+  }
+
+  public float getFuzzyMinSim() {
+    return fuzzyMinSim;
+  }
+
+  public int getFuzzyPrefixLength() {
+    return fuzzyPrefixLength;
+  }
+
+  public Locale getLocale() {
+    return locale;
+  }
+
+  public TimeZone getTimeZone() {
+    return timeZone;
+  }
+
+  public int getPhraseSlop() {
+    return phraseSlop;
+  }
+
+  public Map<String, Class<? extends Number>> getTypeMap() {
+    return typeMap;
+  }
+
+  @Override
+  public String toString() {
+    return "QueryParserConfig: [" +
+        " default operator=" + defaultOperator.name() + ";" +
+        " enable position increment=" + enablePositionIncrements + ";" +
+        " allow leading wildcard=" + allowLeadingWildcard + ";" +
+        " split whitespace=" + splitOnWhitespace + ";" +
+        " generate phrase query=" + autoGeneratePhraseQueries + ";" +
+        " generate multiterm sysnonymsphrase query=" + autoGenerateMultiTermSynonymsPhraseQuery + ";" +
+        " phrase slop=" + phraseSlop + ";" +
+        " date resolution=" + dateResolution.name() +
+        " locale=" + locale.toLanguageTag() + ";" +
+        " time zone=" + timeZone.getID() + ";" +
+        " numeric types=" + String.join(",", getTypeMap().entrySet().stream()
+        .map(e -> e.getKey() + "=" + e.getValue().toString()).collect(Collectors.toSet())) + ";" +
+        "]";
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java
new file mode 100644
index 00000000000..e8c41008a39
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/Search.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.search.Explanation;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+
+/**
+ * A dedicated interface for Luke's Search tab.
+ */
+public interface Search {
+
+  /**
+   * Returns all field names in this index.
+   */
+  Collection<String> getFieldNames();
+
+  /**
+   * Returns field names those are sortable.
+   */
+  Collection<String> getSortableFieldNames();
+
+  /**
+   * Returns field names those are searchable.
+   */
+  Collection<String> getSearchableFieldNames();
+
+  /**
+   * Returns field names those are searchable by range query.
+   */
+  Collection<String> getRangeSearchableFieldNames();
+
+  /**
+   * Returns the current query.
+   */
+  Query getCurrentQuery();
+
+  /**
+   * Parses the specified query expression with given configurations.
+   *
+   * @param expression - query expression
+   * @param defField - default field for the query
+   * @param analyzer - analyzer for parsing query expression
+   * @param config - query parser configuration
+   * @param rewrite - if true, re-written query is returned
+   * @return parsed query
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Query parseQuery(String expression, String defField, Analyzer analyzer, QueryParserConfig config, boolean rewrite);
+
+  /**
+   * Creates the MoreLikeThis query for the specified document with given configurations.
+   *
+   * @param docid - document id
+   * @param mltConfig - MoreLikeThis configuration
+   * @param analyzer - analyzer for analyzing the document
+   * @return MoreLikeThis query
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Query mltQuery(int docid, MLTConfig mltConfig, Analyzer analyzer);
+
+  /**
+   * Searches this index by the query with given configurations.
+   *
+   * @param query - search query
+   * @param simConfig - similarity configuration
+   * @param fieldsToLoad - field names to load
+   * @param pageSize - page size
+   * @param exactHitsCount - if set to true, the exact total hits count is returned.
+   * @return search results
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  SearchResults search(Query query, SimilarityConfig simConfig, Set<String> fieldsToLoad, int pageSize, boolean exactHitsCount);
+
+  /**
+   * Searches this index by the query with given sort criteria and configurations.
+   *
+   * @param query - search query
+   * @param simConfig - similarity configuration
+   * @param sort - sort criteria
+   * @param fieldsToLoad - fields to load
+   * @param pageSize - page size
+   * @param exactHitsCount - if set to true, the exact total hits count is returned.
+   * @return search results
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  SearchResults search(Query query, SimilarityConfig simConfig, Sort sort, Set<String> fieldsToLoad, int pageSize, boolean exactHitsCount);
+
+  /**
+   * Returns the next page for the current query.
+   *
+   * @return search results, or empty if there are no more results
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<SearchResults> nextPage();
+
+  /**
+   * Returns the previous page for the current query.
+   *
+   * @return search results, or empty if there are no more results.
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<SearchResults> prevPage();
+
+  /**
+   * Explains the document for the specified query.
+   *
+   * @param query - query
+   * @param docid - document id to be explained
+   * @return explanations
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Explanation explain(Query query, int docid);
+
+  /**
+   * Returns possible {@link SortField}s for the specified field.
+   *
+   * @param name - field name
+   * @return list of possible sort types
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  List<SortField> guessSortTypes(String name);
+
+  /**
+   * Returns the {@link SortField} for the specified field with the sort type and order.
+   *
+   * @param name - field name
+   * @param type - string representation for a type
+   * @param reverse - if true, descending order is used
+   * @return sort type, or empty if the type is incompatible with the field
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  Optional<SortField> getSortType(String name, String type, boolean reverse);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchFactory.java
new file mode 100644
index 00000000000..b2f97b11e6a
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchFactory.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import org.apache.lucene.index.IndexReader;
+
+/** Factory of {@link Search} */
+public class SearchFactory {
+
+  public Search newInstance(IndexReader reader) {
+    return new SearchImpl(reader);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java
new file mode 100644
index 00000000000..aa25a67288e
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchImpl.java
@@ -0,0 +1,471 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.text.NumberFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.LukeModel;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.queries.mlt.MoreLikeThis;
+import org.apache.lucene.queryparser.classic.ParseException;
+import org.apache.lucene.queryparser.classic.QueryParser;
+import org.apache.lucene.queryparser.flexible.core.QueryNodeException;
+import org.apache.lucene.queryparser.flexible.standard.StandardQueryParser;
+import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig;
+import org.apache.lucene.queryparser.flexible.standard.config.StandardQueryConfigHandler;
+import org.apache.lucene.search.Explanation;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.SortedNumericSortField;
+import org.apache.lucene.search.SortedSetSortField;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopScoreDocCollector;
+import org.apache.lucene.search.TotalHits;
+import org.apache.lucene.search.similarities.BM25Similarity;
+import org.apache.lucene.search.similarities.ClassicSimilarity;
+import org.apache.lucene.search.similarities.Similarity;
+import org.apache.lucene.util.ArrayUtil;
+
+/** Default implementation of {@link Search} */
+public final class SearchImpl extends LukeModel implements Search {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private static final int DEFAULT_PAGE_SIZE = 10;
+
+  private static final int DEFAULT_TOTAL_HITS_THRESHOLD = 1000;
+
+  private final IndexSearcher searcher;
+
+  private int pageSize = DEFAULT_PAGE_SIZE;
+
+  private int currentPage = -1;
+
+  private TotalHits totalHits;
+
+  private ScoreDoc[] docs = new ScoreDoc[0];
+
+  private boolean exactHitsCount;
+
+  private Query query;
+
+  private Sort sort;
+
+  private Set<String> fieldsToLoad;
+
+  /**
+   * Constructs a SearchImpl that holds given {@link IndexReader}
+   * @param reader - the index reader
+   */
+  public SearchImpl(IndexReader reader) {
+    super(reader);
+    this.searcher = new IndexSearcher(reader);
+  }
+
+  @Override
+  public Collection<String> getSortableFieldNames() {
+    return IndexUtils.getFieldNames(reader).stream()
+        .map(f -> IndexUtils.getFieldInfo(reader, f))
+        .filter(info -> !info.getDocValuesType().equals(DocValuesType.NONE))
+        .map(info -> info.name)
+        .collect(Collectors.toList());
+  }
+
+  @Override
+  public Collection<String> getSearchableFieldNames() {
+    return IndexUtils.getFieldNames(reader).stream()
+        .map(f -> IndexUtils.getFieldInfo(reader, f))
+        .filter(info -> !info.getIndexOptions().equals(IndexOptions.NONE))
+        .map(info -> info.name)
+        .collect(Collectors.toList());
+  }
+
+  @Override
+  public Collection<String> getRangeSearchableFieldNames() {
+    return IndexUtils.getFieldNames(reader).stream()
+        .map(f -> IndexUtils.getFieldInfo(reader, f))
+        .filter(info -> info.getPointDataDimensionCount() > 0)
+        .map(info -> info.name)
+        .collect(Collectors.toSet());
+  }
+
+  @Override
+  public Query getCurrentQuery() {
+    return this.query;
+  }
+
+  @Override
+  public Query parseQuery(String expression, String defField, Analyzer analyzer,
+                          QueryParserConfig config, boolean rewrite) {
+    Objects.requireNonNull(expression);
+    Objects.requireNonNull(defField);
+    Objects.requireNonNull(analyzer);
+    Objects.requireNonNull(config);
+
+    Query query = config.isUseClassicParser() ?
+        parseByClassicParser(expression, defField, analyzer, config) :
+        parseByStandardParser(expression, defField, analyzer, config);
+
+    if (rewrite) {
+      try {
+        query = query.rewrite(reader);
+      } catch (IOException e) {
+        throw new LukeException(String.format(Locale.ENGLISH, "Failed to rewrite query: %s", query.toString()), e);
+      }
+    }
+
+    return query;
+  }
+
+  private Query parseByClassicParser(String expression, String defField, Analyzer analyzer,
+                                     QueryParserConfig config) {
+    QueryParser parser = new QueryParser(defField, analyzer);
+
+    switch (config.getDefaultOperator()) {
+      case OR:
+        parser.setDefaultOperator(QueryParser.Operator.OR);
+        break;
+      case AND:
+        parser.setDefaultOperator(QueryParser.Operator.AND);
+        break;
+    }
+
+    parser.setSplitOnWhitespace(config.isSplitOnWhitespace());
+    parser.setAutoGenerateMultiTermSynonymsPhraseQuery(config.isAutoGenerateMultiTermSynonymsPhraseQuery());
+    parser.setAutoGeneratePhraseQueries(config.isAutoGeneratePhraseQueries());
+    parser.setEnablePositionIncrements(config.isEnablePositionIncrements());
+    parser.setAllowLeadingWildcard(config.isAllowLeadingWildcard());
+    parser.setDateResolution(config.getDateResolution());
+    parser.setFuzzyMinSim(config.getFuzzyMinSim());
+    parser.setFuzzyPrefixLength(config.getFuzzyPrefixLength());
+    parser.setLocale(config.getLocale());
+    parser.setTimeZone(config.getTimeZone());
+    parser.setPhraseSlop(config.getPhraseSlop());
+
+    try {
+      return parser.parse(expression);
+    } catch (ParseException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to parse query expression: %s", expression), e);
+    }
+
+  }
+
+  private Query parseByStandardParser(String expression, String defField, Analyzer analyzer,
+                                      QueryParserConfig config) {
+    StandardQueryParser parser = new StandardQueryParser(analyzer);
+
+    switch (config.getDefaultOperator()) {
+      case OR:
+        parser.setDefaultOperator(StandardQueryConfigHandler.Operator.OR);
+        break;
+      case AND:
+        parser.setDefaultOperator(StandardQueryConfigHandler.Operator.AND);
+        break;
+    }
+
+    parser.setEnablePositionIncrements(config.isEnablePositionIncrements());
+    parser.setAllowLeadingWildcard(config.isAllowLeadingWildcard());
+    parser.setDateResolution(config.getDateResolution());
+    parser.setFuzzyMinSim(config.getFuzzyMinSim());
+    parser.setFuzzyPrefixLength(config.getFuzzyPrefixLength());
+    parser.setLocale(config.getLocale());
+    parser.setTimeZone(config.getTimeZone());
+    parser.setPhraseSlop(config.getPhraseSlop());
+
+    if (config.getTypeMap() != null) {
+      Map<String, PointsConfig> pointsConfigMap = new HashMap<>();
+
+      for (Map.Entry<String, Class<? extends Number>> entry : config.getTypeMap().entrySet()) {
+        String field = entry.getKey();
+        Class<? extends Number> type = entry.getValue();
+        PointsConfig pc;
+        if (type == Integer.class || type == Long.class) {
+          pc = new PointsConfig(NumberFormat.getIntegerInstance(Locale.ROOT), type);
+        } else if (type == Float.class || type == Double.class) {
+          pc = new PointsConfig(NumberFormat.getNumberInstance(Locale.ROOT), type);
+        } else {
+          log.warn(String.format(Locale.ENGLISH, "Ignored invalid number type: %s.", type.getName()));
+          continue;
+        }
+        pointsConfigMap.put(field, pc);
+      }
+
+      parser.setPointsConfigMap(pointsConfigMap);
+    }
+
+    try {
+      return parser.parse(expression, defField);
+    } catch (QueryNodeException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to parse query expression: %s", expression), e);
+    }
+
+  }
+
+  @Override
+  public Query mltQuery(int docid, MLTConfig mltConfig, Analyzer analyzer) {
+    MoreLikeThis mlt = new MoreLikeThis(reader);
+
+    mlt.setAnalyzer(analyzer);
+    mlt.setFieldNames(mltConfig.getFieldNames());
+    mlt.setMinDocFreq(mltConfig.getMinDocFreq());
+    mlt.setMaxDocFreq(mltConfig.getMaxDocFreq());
+    mlt.setMinTermFreq(mltConfig.getMinTermFreq());
+
+    try {
+      return mlt.like(docid);
+    } catch (IOException e) {
+      throw new LukeException("Failed to create MLT query for doc: " + docid);
+    }
+  }
+
+  @Override
+  public SearchResults search(
+      Query query, SimilarityConfig simConfig, Set<String> fieldsToLoad, int pageSize, boolean exactHitsCount) {
+    return search(query, simConfig, null, fieldsToLoad, pageSize, exactHitsCount);
+  }
+
+  @Override
+  public SearchResults search(
+      Query query, SimilarityConfig simConfig, Sort sort, Set<String> fieldsToLoad, int pageSize, boolean exactHitsCount) {
+    if (pageSize < 0) {
+      throw new LukeException(new IllegalArgumentException("Negative integer is not acceptable for page size."));
+    }
+
+    // reset internal status to prepare for a new search session
+    this.docs = new ScoreDoc[0];
+    this.currentPage = 0;
+    this.pageSize = pageSize;
+    this.exactHitsCount = exactHitsCount;
+    this.query = Objects.requireNonNull(query);
+    this.sort = sort;
+    this.fieldsToLoad = fieldsToLoad == null ? null : Collections.unmodifiableSet(fieldsToLoad);
+    searcher.setSimilarity(createSimilarity(Objects.requireNonNull(simConfig)));
+
+    try {
+      return search();
+    } catch (IOException e) {
+      throw new LukeException("Search Failed.", e);
+    }
+  }
+
+  private SearchResults search() throws IOException {
+    // execute search
+    ScoreDoc after = docs.length == 0 ? null : docs[docs.length - 1];
+
+    TopDocs topDocs;
+    if (sort != null) {
+      topDocs = searcher.searchAfter(after, query, pageSize, sort);
+    } else {
+      int hitsThreshold = exactHitsCount ? Integer.MAX_VALUE : DEFAULT_TOTAL_HITS_THRESHOLD;
+      TopScoreDocCollector collector = TopScoreDocCollector.create(pageSize, after, hitsThreshold);
+      searcher.search(query, collector);
+      topDocs = collector.topDocs();
+    }
+
+    // reset total hits for the current query
+    this.totalHits = topDocs.totalHits;
+
+    // cache search results for later use
+    ScoreDoc[] newDocs = new ScoreDoc[docs.length + topDocs.scoreDocs.length];
+    System.arraycopy(docs, 0, newDocs, 0, docs.length);
+    System.arraycopy(topDocs.scoreDocs, 0, newDocs, docs.length, topDocs.scoreDocs.length);
+    this.docs = newDocs;
+
+    return SearchResults.of(topDocs.totalHits, topDocs.scoreDocs, currentPage * pageSize, searcher, fieldsToLoad);
+  }
+
+  @Override
+  public Optional<SearchResults> nextPage() {
+    if (currentPage < 0 || query == null) {
+      throw new LukeException(new IllegalStateException("Search session not started."));
+    }
+
+    // proceed to next page
+    currentPage += 1;
+
+    if (totalHits.value == 0 ||
+        (totalHits.relation == TotalHits.Relation.EQUAL_TO && currentPage * pageSize >= totalHits.value)) {
+      log.warn("No more next search results are available.");
+      return Optional.empty();
+    }
+
+    try {
+
+      if (currentPage * pageSize < docs.length) {
+        // if cached results exist, return that.
+        int from = currentPage * pageSize;
+        int to = Math.min(from + pageSize, docs.length);
+        ScoreDoc[] part = ArrayUtil.copyOfSubArray(docs, from, to);
+        return Optional.of(SearchResults.of(totalHits, part, from, searcher, fieldsToLoad));
+      } else {
+        return Optional.of(search());
+      }
+
+    } catch (IOException e) {
+      throw new LukeException("Search Failed.", e);
+    }
+  }
+
+
+  @Override
+  public Optional<SearchResults> prevPage() {
+    if (currentPage < 0 || query == null) {
+      throw new LukeException(new IllegalStateException("Search session not started."));
+    }
+
+    // return to previous page
+    currentPage -= 1;
+
+    if (currentPage < 0) {
+      log.warn("No more previous search results are available.");
+      return Optional.empty();
+    }
+
+    try {
+      // there should be cached results for this page
+      int from = currentPage * pageSize;
+      int to = Math.min(from + pageSize, docs.length);
+      ScoreDoc[] part = ArrayUtil.copyOfSubArray(docs, from, to);
+      return Optional.of(SearchResults.of(totalHits, part, from, searcher, fieldsToLoad));
+    } catch (IOException e) {
+      throw new LukeException("Search Failed.", e);
+    }
+  }
+
+  private Similarity createSimilarity(SimilarityConfig config) {
+    Similarity similarity;
+
+    if (config.isUseClassicSimilarity()) {
+      ClassicSimilarity tfidf = new ClassicSimilarity();
+      tfidf.setDiscountOverlaps(config.isDiscountOverlaps());
+      similarity = tfidf;
+    } else {
+      BM25Similarity bm25 = new BM25Similarity(config.getK1(), config.getB());
+      bm25.setDiscountOverlaps(config.isDiscountOverlaps());
+      similarity = bm25;
+    }
+
+    return similarity;
+  }
+
+  @Override
+  public List<SortField> guessSortTypes(String name) {
+    FieldInfo finfo = IndexUtils.getFieldInfo(reader, name);
+    if (finfo == null) {
+      throw new LukeException("No such field: " + name, new IllegalArgumentException());
+    }
+
+    DocValuesType dvType = finfo.getDocValuesType();
+
+    switch (dvType) {
+      case NONE:
+        return Collections.emptyList();
+
+      case NUMERIC:
+        return Arrays.stream(new SortField[]{
+            new SortField(name, SortField.Type.INT),
+            new SortField(name, SortField.Type.LONG),
+            new SortField(name, SortField.Type.FLOAT),
+            new SortField(name, SortField.Type.DOUBLE)
+        }).collect(Collectors.toList());
+
+      case SORTED_NUMERIC:
+        return Arrays.stream(new SortField[]{
+            new SortedNumericSortField(name, SortField.Type.INT),
+            new SortedNumericSortField(name, SortField.Type.LONG),
+            new SortedNumericSortField(name, SortField.Type.FLOAT),
+            new SortedNumericSortField(name, SortField.Type.DOUBLE)
+        }).collect(Collectors.toList());
+
+      case SORTED:
+        return Arrays.stream(new SortField[] {
+            new SortField(name, SortField.Type.STRING),
+            new SortField(name, SortField.Type.STRING_VAL)
+        }).collect(Collectors.toList());
+
+      case SORTED_SET:
+        return Collections.singletonList(new SortedSetSortField(name, false));
+
+      default:
+        return Collections.singletonList(new SortField(name, SortField.Type.DOC));
+    }
+
+  }
+
+  @Override
+  public Optional<SortField> getSortType(String name, String type, boolean reverse) {
+    Objects.requireNonNull(name);
+    Objects.requireNonNull(type);
+    List<SortField> candidates = guessSortTypes(name);
+    if (candidates.isEmpty()) {
+      log.warn(String.format(Locale.ENGLISH, "No available sort types for: %s", name));
+      return Optional.empty();
+    }
+
+    // TODO should be refactored...
+    for (SortField sf : candidates) {
+      if (sf instanceof SortedSetSortField) {
+        return Optional.of(new SortedSetSortField(sf.getField(), reverse));
+      } else if (sf instanceof SortedNumericSortField) {
+        SortField.Type sfType = ((SortedNumericSortField) sf).getNumericType();
+        if (sfType.name().equals(type)) {
+          return Optional.of(new SortedNumericSortField(sf.getField(), sfType, reverse));
+        }
+      } else {
+        SortField.Type sfType = sf.getType();
+        if (sfType.name().equals(type)) {
+          return Optional.of(new SortField(sf.getField(), sfType, reverse));
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public Explanation explain(Query query, int docid) {
+    try {
+      return searcher.explain(query, docid);
+    } catch (IOException e) {
+      throw new LukeException(String.format(Locale.ENGLISH, "Failed to create explanation for doc: %d for query: \"%s\"", docid, query.toString()), e);
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java
new file mode 100644
index 00000000000..7421e20b606
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SearchResults.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TotalHits;
+
+/**
+ * Holder for a search result page.
+ */
+public final class SearchResults {
+
+  private TotalHits totalHits;
+
+  private int offset = 0;
+
+  private List<Doc> hits = new ArrayList<>();
+
+  /**
+   * Creates a search result page for the given raw Lucene hits.
+   *
+   * @param totalHits - total number of hits for this query
+   * @param docs - array of hits
+   * @param offset - offset of the current page
+   * @param searcher - index searcher
+   * @param fieldsToLoad - fields to load
+   * @return the search result page
+   * @throws IOException - if there is a low level IO error.
+   */
+  static SearchResults of(TotalHits totalHits, ScoreDoc[] docs, int offset,
+                          IndexSearcher searcher, Set<String> fieldsToLoad)
+      throws IOException {
+    SearchResults res = new SearchResults();
+
+    res.totalHits = Objects.requireNonNull(totalHits);
+    Objects.requireNonNull(docs);
+    Objects.requireNonNull(searcher);
+
+    for (ScoreDoc sd : docs) {
+      Document luceneDoc = (fieldsToLoad == null) ?
+          searcher.doc(sd.doc) : searcher.doc(sd.doc, fieldsToLoad);
+      res.hits.add(Doc.of(sd.doc, sd.score, luceneDoc));
+      res.offset = offset;
+    }
+
+    return res;
+  }
+
+  /**
+   * Returns the total number of hits for this query.
+   */
+  public TotalHits getTotalHits() {
+    return totalHits;
+  }
+
+  /**
+   * Returns the offset of the current page.
+   */
+  public int getOffset() {
+    return offset;
+  }
+
+  /**
+   * Returns the documents of the current page.
+   */
+  public List<Doc> getHits() {
+    return Collections.unmodifiableList(hits);
+  }
+
+  /**
+   * Returns the size of the current page.
+   */
+  public int size() {
+    return hits.size();
+  }
+
+  private SearchResults() {
+  }
+
+  /**
+   * Holder for a hit.
+   */
+  public static class Doc {
+    private int docId;
+    private float score;
+    private Map<String, String[]> fieldValues = new HashMap<>();
+
+    /**
+     * Creates a hit.
+     *
+     * @param docId - document id
+     * @param score - score of this document for the query
+     * @param luceneDoc - raw Lucene document
+     * @return the hit
+     */
+    static Doc of(int docId, float score, Document luceneDoc) {
+      Objects.requireNonNull(luceneDoc);
+
+      Doc doc = new Doc();
+      doc.docId = docId;
+      doc.score = score;
+      Set<String> fields = luceneDoc.getFields().stream().map(IndexableField::name).collect(Collectors.toSet());
+      for (String f : fields) {
+        doc.fieldValues.put(f, luceneDoc.getValues(f));
+      }
+      return doc;
+    }
+
+    /**
+     * Returns the document id.
+     */
+    public int getDocId() {
+      return docId;
+    }
+
+    /**
+     * Returns the score of this document for the current query.
+     */
+    public float getScore() {
+      return score;
+    }
+
+    /**
+     * Returns the field data of this document.
+     */
+    public Map<String, String[]> getFieldValues() {
+      return Collections.unmodifiableMap(fieldValues);
+    }
+
+    private Doc() {
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/SimilarityConfig.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SimilarityConfig.java
new file mode 100644
index 00000000000..072d1c54351
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/SimilarityConfig.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+/**
+ * Configurations for Similarity.
+ */
+public final class SimilarityConfig {
+
+  private final boolean useClassicSimilarity;
+
+  /* BM25Similarity parameters */
+
+  private final float k1;
+
+  private final float b;
+
+  /* Common parameters */
+
+  private final boolean discountOverlaps;
+
+  /** Builder for {@link SimilarityConfig} */
+  public static class Builder {
+    private boolean useClassicSimilarity = false;
+    private float k1 = 1.2f;
+    private float b = 0.75f;
+    private boolean discountOverlaps = true;
+
+    public Builder useClassicSimilarity(boolean val) {
+      useClassicSimilarity = val;
+      return this;
+    }
+
+    public Builder k1(float val) {
+      k1 = val;
+      return this;
+    }
+
+    public Builder b(float val) {
+      b = val;
+      return this;
+    }
+
+    public Builder discountOverlaps (boolean val) {
+      discountOverlaps = val;
+      return this;
+    }
+
+    public SimilarityConfig build() {
+      return new SimilarityConfig(this);
+    }
+  }
+
+  private SimilarityConfig(Builder builder) {
+    this.useClassicSimilarity = builder.useClassicSimilarity;
+    this.k1 = builder.k1;
+    this.b = builder.b;
+    this.discountOverlaps = builder.discountOverlaps;
+  }
+
+  public boolean isUseClassicSimilarity() {
+    return useClassicSimilarity;
+  }
+
+  public float getK1() {
+    return k1;
+  }
+
+  public float getB() {
+    return b;
+  }
+
+  public boolean isDiscountOverlaps() {
+    return discountOverlaps;
+  }
+
+  public String toString() {
+    return "SimilarityConfig: [" +
+        " use classic similarity=" + useClassicSimilarity + ";" +
+        " discount overlaps=" + discountOverlaps + ";" +
+        " k1=" + k1 + ";" +
+        " b=" + b + ";" +
+        "]";
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/search/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/search/package-info.java
new file mode 100644
index 00000000000..63433a1bf2c
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/search/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for the Search tab */
+package org.apache.lucene.luke.models.search;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexTools.java b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexTools.java
new file mode 100644
index 00000000000..877646cd4b4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexTools.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.tools;
+
+import java.io.PrintStream;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.CheckIndex;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.search.Query;
+
+/**
+ * A dedicated interface for Luke's various index manipulations.
+ */
+public interface IndexTools {
+
+  /**
+   * Execute force merges.
+   *
+   * <p>
+   * Merges are executed until there are <i>maxNumSegments</i> segments. <br>
+   * When <i>expunge</i> is true, <i>maxNumSegments</i> parameter is ignored.
+   * </p>
+   *
+   * @param expunge - if true, only segments having deleted documents are merged
+   * @param maxNumSegments - max number of segments
+   * @param ps - information stream
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  void optimize(boolean expunge, int maxNumSegments, PrintStream ps);
+
+  /**
+   * Check the current index status.
+   *
+   * @param ps information stream
+   * @return index status
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  CheckIndex.Status checkIndex(PrintStream ps);
+
+  /**
+   * Try to repair the corrupted index using previously returned index status.
+   *
+   * <p>This method must be called with the return value from {@link IndexTools#checkIndex(PrintStream)}.</p>
+   *
+   * @param st - index status
+   * @param ps - information stream
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  void repairIndex(CheckIndex.Status st, PrintStream ps);
+
+  /**
+   * Add new document to this index.
+   *
+   * @param doc - document to be added
+   * @param analyzer - analyzer for parsing to document
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  void addDocument(Document doc, Analyzer analyzer);
+
+  /**
+   * Delete documents from this index by the specified query.
+   *
+   * @param query - query for deleting
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  void deleteDocuments(Query query);
+
+  /**
+   * Create a new index.
+   *
+   * @throws LukeException - if an internal error occurs when accessing index
+   */
+  void createNewIndex();
+
+  /**
+   * Create a new index with sample documents.
+   * @param dataDir - the directory path which contains sample documents (20 Newsgroups).
+   */
+  void createNewIndex(String dataDir);
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsFactory.java
new file mode 100644
index 00000000000..c3bd86376a1
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsFactory.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.tools;
+
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.store.Directory;
+
+/** Factory of {@link IndexTools} */
+public class IndexToolsFactory {
+
+  public IndexTools newInstance(Directory dir) {
+    return new IndexToolsImpl(dir, false, false);
+  }
+
+  public IndexTools newInstance(IndexReader reader, boolean useCompound, boolean keepAllCommits) {
+    return new IndexToolsImpl(reader, useCompound, keepAllCommits);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java
new file mode 100644
index 00000000000..166958b8d10
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.tools;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.CheckIndex;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.luke.models.LukeModel;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.luke.models.util.twentynewsgroups.Message;
+import org.apache.lucene.luke.models.util.twentynewsgroups.MessageFilesParser;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.store.Directory;
+
+/** Default implementation of {@link IndexTools} */
+public final class IndexToolsImpl extends LukeModel implements IndexTools {
+
+  private final boolean useCompound;
+
+  private final boolean keepAllCommits;
+
+  /**
+   * Constructs an IndexToolsImpl that holds given {@link Directory}.
+   *
+   * @param dir - the index directory
+   * @param useCompound - if true, compound file format is used
+   * @param keepAllCommits - if true, all commit points are reserved
+   */
+  public IndexToolsImpl(Directory dir, boolean useCompound, boolean keepAllCommits) {
+    super(dir);
+    this.useCompound = useCompound;
+    this.keepAllCommits = keepAllCommits;
+  }
+
+  /**
+   * Constructs an IndexToolsImpl that holds given {@link IndexReader}.
+   *
+   * @param reader - the index reader
+   * @param useCompound - if true, compound file format is used
+   * @param keepAllCommits - if true, all commit points are reserved
+   */
+  public IndexToolsImpl(IndexReader reader, boolean useCompound, boolean keepAllCommits) {
+    super(reader);
+    this.useCompound = useCompound;
+    this.keepAllCommits = keepAllCommits;
+  }
+
+  @Override
+  public void optimize(boolean expunge, int maxNumSegments, PrintStream ps) {
+    if (reader instanceof DirectoryReader) {
+      Directory dir = ((DirectoryReader) reader).directory();
+      try (IndexWriter writer = IndexUtils.createWriter(dir, null, useCompound, keepAllCommits, ps)) {
+        IndexUtils.optimizeIndex(writer, expunge, maxNumSegments);
+      } catch (IOException e) {
+        throw new LukeException("Failed to optimize index", e);
+      }
+    } else {
+      throw new LukeException("Current reader is not a DirectoryReader.");
+    }
+  }
+
+  @Override
+  public CheckIndex.Status checkIndex(PrintStream ps) {
+    try {
+      if (dir != null) {
+        return IndexUtils.checkIndex(dir, ps);
+      } else if (reader instanceof DirectoryReader) {
+        Directory dir = ((DirectoryReader) reader).directory();
+        return IndexUtils.checkIndex(dir, ps);
+      } else {
+        throw new IllegalStateException("Directory is not set.");
+      }
+    } catch (Exception e) {
+      throw new LukeException("Failed to check index.", e);
+    }
+  }
+
+  @Override
+  public void repairIndex(CheckIndex.Status st, PrintStream ps) {
+    try {
+      if (dir != null) {
+        IndexUtils.tryRepairIndex(dir, st, ps);
+      } else {
+        throw new IllegalStateException("Directory is not set.");
+      }
+    } catch (Exception e) {
+      throw new LukeException("Failed to repair index.", e);
+    }
+  }
+
+  @Override
+  public void addDocument(Document doc, Analyzer analyzer) {
+    Objects.requireNonNull(analyzer);
+
+    if (reader instanceof DirectoryReader) {
+      Directory dir = ((DirectoryReader) reader).directory();
+      try (IndexWriter writer = IndexUtils.createWriter(dir, analyzer, useCompound, keepAllCommits)) {
+        writer.addDocument(doc);
+        writer.commit();
+      } catch (IOException e) {
+        throw new LukeException("Failed to add document", e);
+      }
+    } else {
+      throw new LukeException("Current reader is not an instance of DirectoryReader.");
+    }
+  }
+
+  @Override
+  public void deleteDocuments(Query query) {
+    Objects.requireNonNull(query);
+
+    if (reader instanceof DirectoryReader) {
+      Directory dir = ((DirectoryReader) reader).directory();
+      try (IndexWriter writer = IndexUtils.createWriter(dir, null, useCompound, keepAllCommits)) {
+        writer.deleteDocuments(query);
+        writer.commit();
+      } catch (IOException e) {
+        throw new LukeException("Failed to add document", e);
+      }
+    } else {
+      throw new LukeException("Current reader is not an instance of DirectoryReader.");
+    }
+  }
+
+  @Override
+  public void createNewIndex() {
+    createNewIndex(null);
+  }
+
+  @Override
+  public void createNewIndex(String dataDir) {
+    IndexWriter writer = null;
+    try {
+      if (dir == null || dir.listAll().length > 0) {
+        // Directory is null or not empty
+        throw new IllegalStateException();
+      }
+
+      writer = IndexUtils.createWriter(dir, Message.createLuceneAnalyzer(), useCompound, keepAllCommits);
+
+      if (Objects.nonNull(dataDir)) {
+        Path path = Paths.get(dataDir);
+        MessageFilesParser parser = new MessageFilesParser(path);
+        List<Message> messages = parser.parseAll();
+        for (Message message : messages) {
+          writer.addDocument(message.toLuceneDoc());
+        }
+      }
+
+      writer.commit();
+    } catch (IOException e) {
+      throw new LukeException("Cannot create new index.", e);
+    } finally {
+      if (writer != null) {
+        try {
+          writer.close();
+        } catch (IOException e) {}
+      }
+    }
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/tools/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/package-info.java
new file mode 100644
index 00000000000..cb76b17725e
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/tools/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Models and APIs for various index manipulation */
+package org.apache.lucene.luke.models.tools;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java
new file mode 100644
index 00000000000..e59689a4c29
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/util/IndexUtils.java
@@ -0,0 +1,497 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.util;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Constructor;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.index.*;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.IOContext;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.store.LockFactory;
+import org.apache.lucene.util.Bits;
+
+/**
+ * Utilities for various raw index operations.
+ *
+ * <p>
+ * This is for internal uses, DO NOT call from UI components or applications.
+ * </p>
+ */
+public final class IndexUtils {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /**
+   * Opens index(es) reader for given index path.
+   *
+   * @param indexPath - path to the index directory
+   * @param dirImpl - class name for the specific directory implementation
+   * @return index reader
+   * @throws Exception - if there is a low level IO error.
+   */
+  public static IndexReader openIndex(String indexPath, String dirImpl)
+      throws Exception {
+    final Path root = FileSystems.getDefault().getPath(Objects.requireNonNull(indexPath));
+    final List<DirectoryReader> readers = new ArrayList<>();
+
+    // find all valid index directories in this directory
+    Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
+      @Override
+      public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
+        Directory dir = openDirectory(path, dirImpl);
+        try {
+          DirectoryReader dr = DirectoryReader.open(dir);
+          readers.add(dr);
+        } catch (IOException e) {
+          log.warn(e.getMessage(), e);
+        }
+        return FileVisitResult.CONTINUE;
+      }
+    });
+
+    if (readers.isEmpty()) {
+      throw new RuntimeException("No valid directory at the location: " + indexPath);
+    }
+
+    log.info(String.format(Locale.ENGLISH, "IndexReaders (%d leaf readers) successfully opened. Index path=%s", readers.size(), indexPath));
+
+    if (readers.size() == 1) {
+      return readers.get(0);
+    } else {
+      return new MultiReader(readers.toArray(new IndexReader[readers.size()]));
+    }
+  }
+
+  /**
+   * Opens an index directory for given index path.
+   *
+   * <p>This can be used to open/repair corrupted indexes.</p>
+   *
+   * @param dirPath - index directory path
+   * @param dirImpl - class name for the specific directory implementation
+   * @return directory
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static Directory openDirectory(String dirPath, String dirImpl) throws IOException {
+    final Path path = FileSystems.getDefault().getPath(Objects.requireNonNull(dirPath));
+    Directory dir = openDirectory(path, dirImpl);
+    log.info(String.format(Locale.ENGLISH, "DirectoryReader successfully opened. Directory path=%s", dirPath));
+    return dir;
+  }
+
+  private static Directory openDirectory(Path path, String dirImpl) throws IOException {
+    if (!Files.exists(Objects.requireNonNull(path))) {
+      throw new IllegalArgumentException("Index directory doesn't exist.");
+    }
+
+    Directory dir;
+    if (dirImpl == null || dirImpl.equalsIgnoreCase("org.apache.lucene.store.FSDirectory")) {
+      dir = FSDirectory.open(path);
+    } else {
+      try {
+        Class<?> implClazz = Class.forName(dirImpl);
+        Constructor<?> constr = implClazz.getConstructor(Path.class);
+        if (constr != null) {
+          dir = (Directory) constr.newInstance(path);
+        } else {
+          constr = implClazz.getConstructor(Path.class, LockFactory.class);
+          dir = (Directory) constr.newInstance(path, null);
+        }
+      } catch (Exception e) {
+        log.warn(e.getMessage(), e);
+        throw new IllegalArgumentException("Invalid directory implementation class: " + dirImpl);
+      }
+    }
+    return dir;
+  }
+
+  /**
+   * Close index directory.
+   *
+   * @param dir - index directory to be closed
+   */
+  public static void close(Directory dir) {
+    try {
+      if (dir != null) {
+        dir.close();
+        log.info("Directory successfully closed.");
+      }
+    } catch (IOException e) {
+      log.error(e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Close index reader.
+   *
+   * @param reader - index reader to be closed
+   */
+  public static void close(IndexReader reader) {
+    try {
+      if (reader != null) {
+        reader.close();
+        log.info("IndexReader successfully closed.");
+        if (reader instanceof DirectoryReader) {
+          Directory dir = ((DirectoryReader) reader).directory();
+          dir.close();
+          log.info("Directory successfully closed.");
+        }
+      }
+    } catch (IOException e) {
+      log.error(e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Create an index writer.
+   *
+   * @param dir - index directory
+   * @param analyzer - analyzer used by the index writer
+   * @param useCompound - if true, compound index files are used
+   * @param keepAllCommits - if true, all commit generations are kept
+   * @return new index writer
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static IndexWriter createWriter(Directory dir, Analyzer analyzer, boolean useCompound, boolean keepAllCommits) throws IOException {
+    return createWriter(Objects.requireNonNull(dir), analyzer, useCompound, keepAllCommits, null);
+  }
+
+  /**
+   * Create an index writer.
+   *
+   * @param dir - index directory
+   * @param analyzer - analyser used by the index writer
+   * @param useCompound - if true, compound index files are used
+   * @param keepAllCommits - if true, all commit generations are kept
+   * @param ps - information stream
+   * @return new index writer
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static IndexWriter createWriter(Directory dir, Analyzer analyzer, boolean useCompound, boolean keepAllCommits,
+                                         PrintStream ps) throws IOException {
+    Objects.requireNonNull(dir);
+
+    IndexWriterConfig config = new IndexWriterConfig(analyzer == null ? new WhitespaceAnalyzer() : analyzer);
+    config.setUseCompoundFile(useCompound);
+    if (ps != null) {
+      config.setInfoStream(ps);
+    }
+    if (keepAllCommits) {
+      config.setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
+    } else {
+      config.setIndexDeletionPolicy(new KeepOnlyLastCommitDeletionPolicy());
+    }
+
+    return new IndexWriter(dir, config);
+  }
+
+  /**
+   * Execute force merge with the index writer.
+   *
+   * @param writer - index writer
+   * @param expunge - if true, only segments having deleted documents are merged
+   * @param maxNumSegments - max number of segments
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static void optimizeIndex(IndexWriter writer, boolean expunge, int maxNumSegments) throws IOException {
+    Objects.requireNonNull(writer);
+    if (expunge) {
+      writer.forceMergeDeletes(true);
+    } else {
+      writer.forceMerge(maxNumSegments, true);
+    }
+  }
+
+  /**
+   * Check the index status.
+   *
+   * @param dir - index directory for checking
+   * @param ps - information stream
+   * @return - index status
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static CheckIndex.Status checkIndex(Directory dir, PrintStream ps) throws IOException {
+    Objects.requireNonNull(dir);
+
+    try (CheckIndex ci = new CheckIndex(dir)) {
+      if (ps != null) {
+        ci.setInfoStream(ps);
+      }
+      return ci.checkIndex();
+    }
+  }
+
+  /**
+   * Try to repair the corrupted index using previously returned index status.
+   *
+   * @param dir - index directory for repairing
+   * @param st - index status
+   * @param ps - information stream
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static void tryRepairIndex(Directory dir, CheckIndex.Status st, PrintStream ps) throws IOException {
+    Objects.requireNonNull(dir);
+    Objects.requireNonNull(st);
+
+    try (CheckIndex ci = new CheckIndex(dir)) {
+      if (ps != null) {
+        ci.setInfoStream(ps);
+      }
+      ci.exorciseIndex(st);
+    }
+  }
+
+  /**
+   * Returns the string representation for Lucene codec version when the index was written.
+   *
+   * @param dir - index directory
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static String getIndexFormat(Directory dir) throws IOException {
+    Objects.requireNonNull(dir);
+
+    return new SegmentInfos.FindSegmentsFile<String>(dir) {
+      @Override
+      protected String doBody(String segmentFileName) throws IOException {
+        String format = "unknown";
+        try (IndexInput in = dir.openInput(segmentFileName, IOContext.READ)) {
+          if (CodecUtil.CODEC_MAGIC == in.readInt()) {
+            int actualVersion = CodecUtil.checkHeaderNoMagic(in, "segments", SegmentInfos.VERSION_70, Integer.MAX_VALUE);
+            if (actualVersion == SegmentInfos.VERSION_70) {
+              format = "Lucene 7.0 or later";
+            } else if (actualVersion == SegmentInfos.VERSION_72) {
+              format = "Lucene 7.2 or later";
+            } else if (actualVersion == SegmentInfos.VERSION_74) {
+              format = "Lucene 7.4 or later";
+            } else if (actualVersion > SegmentInfos.VERSION_74) {
+              format = "Lucene 7.4 or later (UNSUPPORTED)";
+            }
+          } else {
+            format = "Lucene 6.x or prior (UNSUPPORTED)";
+          }
+        }
+        return format;
+      }
+    }.run();
+  }
+
+  /**
+   * Returns user data written with the specified commit.
+   *
+   * @param ic - index commit
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static String getCommitUserData(IndexCommit ic) throws IOException {
+    Map<String, String> userDataMap = Objects.requireNonNull(ic).getUserData();
+    if (userDataMap != null) {
+      return userDataMap.toString();
+    } else {
+      return "--";
+    }
+  }
+
+  /**
+   * Collect all terms and their counts in the specified fields.
+   *
+   * @param reader - index reader
+   * @param fields - field names
+   * @return a map contains terms and their occurrence frequencies
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static Map<String, Long> countTerms(IndexReader reader, Collection<String> fields) throws IOException {
+    Map<String, Long> res = new HashMap<>();
+    for (String field : fields) {
+      if (!res.containsKey(field)) {
+        res.put(field, 0L);
+      }
+      Terms terms = MultiTerms.getTerms(reader, field);
+      if (terms != null) {
+        TermsEnum te = terms.iterator();
+        while (te.next() != null) {
+          res.put(field, res.get(field) + 1);
+        }
+      }
+    }
+    return res;
+  }
+
+  /**
+   * Returns the {@link Bits} representing live documents in the index.
+   *
+   * @param reader - index reader
+   */
+  public static Bits getLiveDocs(IndexReader reader) {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getLiveDocs();
+    } else {
+      return MultiBits.getLiveDocs(reader);
+    }
+  }
+
+  /**
+   * Returns field {@link FieldInfos} in the index.
+   *
+   * @param reader - index reader
+   */
+  public static FieldInfos getFieldInfos(IndexReader reader) {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getFieldInfos();
+    } else {
+      return FieldInfos.getMergedFieldInfos(reader);
+    }
+  }
+
+  /**
+   * Returns the {@link FieldInfo} referenced by the field.
+   *
+   * @param reader - index reader
+   * @param fieldName - field name
+   */
+  public static FieldInfo getFieldInfo(IndexReader reader, String fieldName) {
+    return getFieldInfos(reader).fieldInfo(fieldName);
+  }
+
+  /**
+   * Returns all field names in the index.
+   *
+   * @param reader - index reader
+   */
+  public static Collection<String> getFieldNames(IndexReader reader) {
+    return StreamSupport.stream(getFieldInfos(reader).spliterator(), false)
+        .map(f -> f.name)
+        .collect(Collectors.toList());
+  }
+
+  /**
+   * Returns the {@link Terms} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static Terms getTerms(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).terms(field);
+    } else {
+      return MultiTerms.getTerms(reader, field);
+    }
+  }
+
+  /**
+   * Returns the {@link BinaryDocValues} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static BinaryDocValues getBinaryDocValues(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getBinaryDocValues(field);
+    } else {
+      return MultiDocValues.getBinaryValues(reader, field);
+    }
+  }
+
+  /**
+   * Returns the {@link NumericDocValues} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static NumericDocValues getNumericDocValues(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getNumericDocValues(field);
+    } else {
+      return MultiDocValues.getNumericValues(reader, field);
+    }
+  }
+
+  /**
+   * Returns the {@link SortedNumericDocValues} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static SortedNumericDocValues getSortedNumericDocValues(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getSortedNumericDocValues(field);
+    } else {
+      return MultiDocValues.getSortedNumericValues(reader, field);
+    }
+  }
+
+  /**
+   * Returns the {@link SortedDocValues} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static SortedDocValues getSortedDocValues(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getSortedDocValues(field);
+    } else {
+      return MultiDocValues.getSortedValues(reader, field);
+    }
+  }
+
+  /**
+   * Returns the {@link SortedSetDocValues} for the specified field.
+   *
+   * @param reader - index reader
+   * @param field - field name
+   * @throws IOException - if there is a low level IO error.
+   */
+  public static SortedSetDocValues getSortedSetDocvalues(IndexReader reader, String field) throws IOException {
+    if (reader instanceof LeafReader) {
+      return ((LeafReader) reader).getSortedSetDocValues(field);
+    } else {
+      return MultiDocValues.getSortedSetValues(reader, field);
+    }
+  }
+
+  private IndexUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/util/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/util/package-info.java
new file mode 100644
index 00000000000..29354bd9273
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/util/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Utilities for models and APIs */
+package org.apache.lucene.luke.models.util;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/Message.java b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/Message.java
new file mode 100644
index 00000000000..e62d2c052d4
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/Message.java
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.util.twentynewsgroups;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.analysis.standard.UAX29URLEmailAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.util.BytesRef;
+
+/** Data holder class for a newsgroups message */
+public class Message {
+
+  private String from;
+  private String[] newsgroups;
+  private String subject;
+  private String messageId;
+  private String date;
+  private String organization;
+  private String body;
+  private int lines;
+
+  public String getFrom() {
+    return from;
+  }
+
+  public void setFrom(String from) {
+    this.from = from;
+  }
+
+  public String[] getNewsgroups() {
+    return newsgroups;
+  }
+
+  public void setNewsgroups(String[] newsgroups) {
+    this.newsgroups = newsgroups;
+  }
+
+  public String getSubject() {
+    return subject;
+  }
+
+  public void setSubject(String subject) {
+    this.subject = subject;
+  }
+
+  public String getMessageId() {
+    return messageId;
+  }
+
+  public void setMessageId(String messageId) {
+    this.messageId = messageId;
+  }
+
+  public String getDate() {
+    return date;
+  }
+
+  public void setDate(String date) {
+    this.date = date;
+  }
+
+  public String getOrganization() {
+    return organization;
+  }
+
+  public void setOrganization(String organization) {
+    this.organization = organization;
+  }
+
+  public String getBody() {
+    return body;
+  }
+
+  public void setBody(String body) {
+    this.body = body;
+  }
+
+  public int getLines() {
+    return lines;
+  }
+
+  public void setLines(int lines) {
+    this.lines = lines;
+  }
+
+  public Document toLuceneDoc() {
+    Document doc = new Document();
+
+    if (Objects.nonNull(getFrom())) {
+      doc.add(new TextField("from", getFrom(), Field.Store.YES));
+    }
+
+    if (Objects.nonNull(getNewsgroups())) {
+      for (String newsgroup : getNewsgroups()) {
+        doc.add(new StringField("newsgroup", newsgroup, Field.Store.YES));
+        doc.add(new SortedSetDocValuesField("newsgroup_sort", new BytesRef(newsgroup)));
+      }
+    }
+
+    if (Objects.nonNull(getSubject())) {
+      doc.add(new Field("subject", getSubject(), SUBJECT_FIELD_TYPE));
+    }
+
+    if (Objects.nonNull(getMessageId())) {
+      doc.add(new StringField("messageId", getMessageId(), Field.Store.YES));
+    }
+
+    if (Objects.nonNull(getDate())) {
+      doc.add(new StoredField("date_raw", getDate()));
+    }
+
+
+    if (getOrganization() != null) {
+      doc.add(new TextField("organization", getOrganization(), Field.Store.YES));
+    }
+
+    doc.add(new IntPoint("lines_range", getLines()));
+    doc.add(new SortedNumericDocValuesField("lines_sort", getLines()));
+    doc.add(new StoredField("lines_raw", String.valueOf(getLines())));
+
+    if (Objects.nonNull(getBody())) {
+      doc.add(new Field("body", getBody(), BODY_FIELD_TYPE));
+    }
+
+    return doc;
+  }
+
+  public static Analyzer createLuceneAnalyzer() {
+    Map<String, Analyzer> map = new HashMap<>();
+    map.put("from", new UAX29URLEmailAnalyzer());
+    return new PerFieldAnalyzerWrapper(new StandardAnalyzer(), map);
+  }
+
+  private final static FieldType SUBJECT_FIELD_TYPE;
+
+  private final static FieldType BODY_FIELD_TYPE;
+
+  static {
+    SUBJECT_FIELD_TYPE = new FieldType();
+    SUBJECT_FIELD_TYPE.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
+    SUBJECT_FIELD_TYPE.setTokenized(true);
+    SUBJECT_FIELD_TYPE.setStored(true);
+
+    BODY_FIELD_TYPE = new FieldType();
+    BODY_FIELD_TYPE.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
+    BODY_FIELD_TYPE.setTokenized(true);
+    BODY_FIELD_TYPE.setStored(true);
+    BODY_FIELD_TYPE.setStoreTermVectors(true);
+    BODY_FIELD_TYPE.setStoreTermVectorPositions(true);
+    BODY_FIELD_TYPE.setStoreTermVectorOffsets(true);
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/MessageFilesParser.java b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/MessageFilesParser.java
new file mode 100644
index 00000000000..5a2fe739849
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/MessageFilesParser.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.util.twentynewsgroups;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+/** 20 Newsgroups (http://kdd.ics.uci.edu/databases/20newsgroups/20newsgroups.html) message files parser */
+public class MessageFilesParser  extends SimpleFileVisitor<Path> {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final Path root;
+
+  private final List<Message> messages = new ArrayList<>();
+
+  public MessageFilesParser(Path root) {
+    this.root = root;
+  }
+
+  public FileVisitResult visitFile(Path file, BasicFileAttributes attr) {
+    try {
+      if (attr.isRegularFile()) {
+        Message message = parse(file);
+        if (message != null) {
+          messages.add(parse(file));
+        }
+      }
+    } catch (IOException e) {
+      log.warn("Invalid file? " + file.toString());
+    }
+    return FileVisitResult.CONTINUE;
+  }
+
+  Message parse(Path file) throws IOException {
+    try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
+      String line = br.readLine();
+
+      Message message = new Message();
+      while (!line.equals("")) {
+        String[] ary = line.split(":", 2);
+        if (ary.length < 2) {
+          line = br.readLine();
+          continue;
+        }
+        String att = ary[0].trim();
+        String val = ary[1].trim();
+        switch (att) {
+          case "From":
+            message.setFrom(val);
+            break;
+          case "Newsgroups":
+            message.setNewsgroups(val.split(","));
+            break;
+          case "Subject":
+            message.setSubject(val);
+            break;
+          case "Message-ID":
+            message.setMessageId(val);
+            break;
+          case "Date":
+            message.setDate(val);
+            break;
+          case "Organization":
+            message.setOrganization(val);
+            break;
+          case "Lines":
+            try {
+              message.setLines(Integer.parseInt(ary[1].trim()));
+            } catch (NumberFormatException e) {}
+            break;
+          default:
+            break;
+        }
+
+        line = br.readLine();
+      }
+
+      StringBuilder sb = new StringBuilder();
+      while (line != null) {
+        sb.append(line);
+        sb.append(" ");
+        line = br.readLine();
+      }
+      message.setBody(sb.toString());
+
+      return message;
+    }
+  }
+
+  public List<Message> parseAll() throws IOException {
+    Files.walkFileTree(root, this);
+    return messages;
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/package-info.java
new file mode 100644
index 00000000000..58218fb3ea3
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/models/util/twentynewsgroups/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Utilities for indexing 20 Newsgroups data */
+package org.apache.lucene.luke.models.util.twentynewsgroups;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/package-info.java
new file mode 100644
index 00000000000..9c6a51e1c8b
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Luke : Lucene toolbox project */
+package org.apache.lucene.luke;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/BytesRefUtils.java b/lucene/luke/src/java/org/apache/lucene/luke/util/BytesRefUtils.java
new file mode 100644
index 00000000000..4c7cf18657f
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/BytesRefUtils.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.util;
+
+import org.apache.lucene.util.BytesRef;
+
+/**
+ * An utility class for handling {@link BytesRef} objects.
+ */
+public final class BytesRefUtils {
+
+  public static String decode(BytesRef ref) {
+    try {
+      return ref.utf8ToString();
+    } catch (Exception e) {
+      return ref.toString();
+    }
+  }
+
+  private BytesRefUtils() {
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/LoggerFactory.java b/lucene/luke/src/java/org/apache/lucene/luke/util/LoggerFactory.java
new file mode 100644
index 00000000000..4735d64ad56
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/LoggerFactory.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.util;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.FileAppender;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder;
+import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
+import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+import org.apache.lucene.luke.app.desktop.util.TextAreaAppender;
+
+/**
+ * Logger factory. This programmatically configurates logger context (Appenders etc.)
+ */
+public class LoggerFactory {
+
+  public static void initGuiLogging(String logFile) {
+    ConfigurationBuilder<BuiltConfiguration> builder = ConfigurationBuilderFactory.newConfigurationBuilder();
+    builder.add(builder.newRootLogger(Level.INFO));
+    LoggerContext context = Configurator.initialize(builder.build());
+
+    PatternLayout layout = PatternLayout.newBuilder()
+        .withPattern("[%d{ISO8601}] %5p (%F:%L) - %m%n")
+        .withCharset(StandardCharsets.UTF_8)
+        .build();
+
+    Appender fileAppender = FileAppender.newBuilder()
+        .setName("File")
+        .setLayout(layout)
+        .withFileName(logFile)
+        .withAppend(false)
+          .build();
+    fileAppender.start();
+
+    Appender textAreaAppender = TextAreaAppender.newBuilder()
+        .setName("TextArea")
+        .setLayout(layout)
+        .build();
+    textAreaAppender.start();
+
+    context.getRootLogger().addAppender(fileAppender);
+    context.getRootLogger().addAppender(textAreaAppender);
+    context.updateLoggers();
+  }
+
+  public static Logger getLogger(Class<?> clazz) {
+    return LogManager.getLogger(clazz);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/util/package-info.java
new file mode 100644
index 00000000000..e9830cf28e6
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** General utilities */
+package org.apache.lucene.luke.util;
\ No newline at end of file
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/ClassScanner.java b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/ClassScanner.java
new file mode 100644
index 00000000000..2937298aee2
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/ClassScanner.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.util.reflection;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.util.LoggerFactory;
+import org.apache.lucene.util.NamedThreadFactory;
+
+/**
+ * Utility class for scanning class files in jars.
+ */
+public class ClassScanner {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final String packageName;
+  private final ClassLoader[] classLoaders;
+
+  public ClassScanner(String packageName, ClassLoader... classLoaders) {
+    this.packageName = packageName;
+    this.classLoaders = classLoaders;
+  }
+
+  public <T> Set<Class<? extends T>> scanSubTypes(Class<T> superType) {
+    final int numThreads = Runtime.getRuntime().availableProcessors();
+
+    List<SubtypeCollector<T>> collectors = new ArrayList<>();
+    for (int i = 0; i < numThreads; i++) {
+      collectors.add(new SubtypeCollector<T>(superType, packageName, classLoaders));
+    }
+
+    try {
+      List<URL> urls = getJarUrls();
+      for (int i = 0; i < urls.size(); i++) {
+        collectors.get(i % numThreads).addUrl(urls.get(i));
+      }
+
+      ExecutorService executorService = Executors.newFixedThreadPool(numThreads, new NamedThreadFactory("scanner-scan-subtypes"));
+      for (SubtypeCollector<T> collector : collectors) {
+        executorService.submit(collector);
+      }
+
+      try {
+        executorService.shutdown();
+        executorService.awaitTermination(10, TimeUnit.SECONDS);
+      } catch (InterruptedException e) {
+      } finally {
+        executorService.shutdownNow();
+      }
+
+      Set<Class<? extends T>> types = new HashSet<>();
+      for (SubtypeCollector<T> collector : collectors) {
+        types.addAll(collector.getTypes());
+      }
+      return types;
+    } catch (IOException e) {
+      log.error("Cannot load jar file entries", e);
+    }
+    return Collections.emptySet();
+  }
+
+  private List<URL> getJarUrls() throws IOException {
+    List<URL> urls = new ArrayList<>();
+    String resourceName = resourceName(packageName);
+    for (ClassLoader loader : classLoaders) {
+      for (Enumeration<URL> e = loader.getResources(resourceName); e.hasMoreElements(); ) {
+        URL url = e.nextElement();
+        // extract jar file path from the resource name
+        int index = url.getPath().lastIndexOf(".jar");
+        if (index > 0) {
+          String path = url.getPath().substring(0, index + 4);
+          urls.add(new URL(path));
+        }
+      }
+    }
+    return  urls;
+  }
+
+  private static String resourceName(String packageName) {
+    if (packageName == null || packageName.equals("")) {
+      return packageName;
+    }
+    return packageName.replace('.', '/');
+  }
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/SubtypeCollector.java b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/SubtypeCollector.java
new file mode 100644
index 00000000000..f10d1316615
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/SubtypeCollector.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.util.reflection;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.jar.JarInputStream;
+import java.util.zip.ZipEntry;
+
+import org.apache.logging.log4j.Logger;
+import org.apache.lucene.luke.util.LoggerFactory;
+
+final class SubtypeCollector<T> implements Runnable {
+
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final Set<URL> urls = new HashSet<>();
+
+  private final Class<T> superType;
+
+  private final String packageName;
+
+  private final ClassLoader[] classLoaders;
+
+  private final Set<Class<? extends T>> types = new HashSet<>();
+
+  SubtypeCollector(Class<T> superType, String packageName, ClassLoader... classLoaders) {
+    this.superType = superType;
+    this.packageName = packageName;
+    this.classLoaders = classLoaders;
+  }
+
+  void addUrl(URL url) {
+    urls.add(url);
+  }
+
+  Set<Class<? extends T>> getTypes() {
+    return Collections.unmodifiableSet(types);
+  }
+
+  @Override
+  public void run() {
+    for (URL url : urls) {
+      try (JarInputStream jis = new JarInputStream(url.openStream())) {
+        // iterate all zip entry in the jar
+        ZipEntry entry;
+        while ((entry = jis.getNextEntry()) != null) {
+          String name = entry.getName();
+          if (name.endsWith(".class") && name.indexOf('$') < 0
+              && !name.contains("package-info") && !name.startsWith("META-INF")) {
+            String fqcn = convertToFQCN(name);
+            if (!fqcn.startsWith(packageName)) {
+              continue;
+            }
+            for (ClassLoader cl : classLoaders) {
+              try {
+                Class<?> clazz = Class.forName(fqcn, false, cl);
+                if (superType.isAssignableFrom(clazz) && !Objects.equals(superType, clazz)) {
+                  types.add(clazz.asSubclass(superType));
+                }
+                break;
+              } catch (Throwable e) {
+              }
+            }
+          }
+        }
+      } catch (IOException e) {
+        log.error("Cannot load jar " + url.toString(), e);
+      }
+    }
+  }
+
+  private static String convertToFQCN(String name) {
+    if (name == null || name.equals("")) {
+      return name;
+    }
+    int index = name.lastIndexOf(".class");
+    return name.replace('/', '.').substring(0, index);
+  }
+
+}
diff --git a/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/package-info.java b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/package-info.java
new file mode 100644
index 00000000000..268245e2ad7
--- /dev/null
+++ b/lucene/luke/src/java/org/apache/lucene/luke/util/reflection/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Utilities for reflections */
+package org.apache.lucene.luke.util.reflection;
\ No newline at end of file
diff --git a/lucene/luke/src/java/overview.html b/lucene/luke/src/java/overview.html
new file mode 100644
index 00000000000..534560c124e
--- /dev/null
+++ b/lucene/luke/src/java/overview.html
@@ -0,0 +1,26 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>Luke</title>
+</head>
+<body>
+Luke - Lucene Toolbox
+</body>
+</html>
\ No newline at end of file
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/font/ElegantIcons.ttf b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/font/ElegantIcons.ttf
new file mode 100644
index 00000000000..12ff680025e
Binary files /dev/null and b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/font/ElegantIcons.ttf differ
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/indicator.gif b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/indicator.gif
new file mode 100644
index 00000000000..d0bce154234
Binary files /dev/null and b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/indicator.gif differ
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene-logo.gif b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene-logo.gif
new file mode 100755
index 00000000000..0317bbb1608
Binary files /dev/null and b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene-logo.gif differ
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene.gif b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene.gif
new file mode 100755
index 00000000000..b4eeddb3c38
Binary files /dev/null and b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/lucene.gif differ
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/luke-logo.gif b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/luke-logo.gif
new file mode 100755
index 00000000000..4ec2fff11d8
Binary files /dev/null and b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/img/luke-logo.gif differ
diff --git a/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/messages/messages.properties b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/messages/messages.properties
new file mode 100644
index 00000000000..94fe4063140
--- /dev/null
+++ b/lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/messages/messages.properties
@@ -0,0 +1,280 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Common
+label.status=Status:
+label.help=Help
+label.int_required=(integer value required)
+label.float_required=(float value required)
+button.copy=Copy to Clipboard
+button.close=Close
+button.ok=OK
+button.cancel=Cancel
+button.browse=Browse
+button.create=Create
+button.clear=Clear
+message.index_opened=Index successfully opened.
+message.index_opened_ro=Index successfully opened. (read-only)
+message.index_opened_multi=Index successfully opened. (multi-reader)
+message.directory_opened=Directory opened. There is no IndexReader - most functionalities are disabled.
+message.index_closed=Index closed.
+message.directory_closed=Directory closed.
+message.error.unknown=Unknown error occurred. Check logs for details.
+tooltip.read_only=read only - write operations are not allowed.
+tooltip.multi_reader=multi reader - write operations are not allowed; some functionalities are not available.
+tooltip.no_reader=no index reader - most functionalities are disabled.
+# Main window
+window.title=Luke: Lucene Toolbox Project
+# Menubar
+menu.file=File
+menu.tools=Tools
+menu.settings=Settings
+menu.color=Color themes
+menu.help=Help
+menu.item.open_index=Open index
+menu.item.reopen_index=Reopen current index
+menu.item.create_index=Create new index
+menu.item.close_index=Close index
+menu.item.exit=Exit
+menu.item.optimize=Optimize index
+menu.item.check_index=Check index
+menu.item.theme_gray=Gray
+menu.item.theme_classic=Classic
+menu.item.theme_sandstone=Sandstone
+menu.item.theme_navy=Navy
+menu.item.about=About
+# Open index
+openindex.dialog.title=Choose index directory path
+openindex.label.index_path=Index Path:
+openindex.label.expert=[Expert options]
+openindex.label.dir_impl=Directory implementation:
+openindex.label.iw_config=IndexWriter Config:
+openindex.checkbox.readonly=Open in Read-only mode
+openindex.checkbox.no_reader=Do not open IndexReader (when opening currupted index)
+openindex.checkbox.use_compound=Use compound file format
+openindex.radio.keep_only_last_commit=Keep only last commit point
+openindex.radio.keep_all_commits=Keep all commit points
+openindex.message.index_path_not_selected=Please choose index path.
+openindex.message.index_path_invalid=Cannot open index path {0}. Not a valid lucene index directory or corrupted?
+openindex.message.index_opened=Index successfully opened.
+openindex.message.index_opened_ro=Index successfully opened. (read-only)
+openindex.message.index_opened_multi=Index successfully opened. (multi-reader)
+openindex.message.dirctory_opened=Directory opened. There is no IndexReader - most functionalities are disabled.
+# Create index
+createindex.dialog.title=Choose new index directory path
+createindex.label.location=Location:
+createindex.label.dirname=Index directory name:
+createindex.label.option=(Options)
+createindex.label.data_link=http://kdd.ics.uci.edu/databases/20newsgroups/20newsgroups.html
+createindex.label.datadir=Data directory:
+createindex.textarea.data_help1=You can index sample documents from 20 Newsgroups corpus that is available at here:
+createindex.textarea.data_help2=Download and extract the tgz file, then select the extracted directory path.\nCreating an index with the full size corpus takes some time... :)
+# Optimize index
+optimize.dialog.title=Optimize index
+optimize.label.index_path=Index directory path:
+optimize.label.max_segments=Max num. of segments: 
+optimize.label.note=Note: Fully optimizing a large index takes long time.
+optimize.checkbox.expunge=Just expunge deleted docs without merging.
+optimize.button.optimize=Optimize
+# Check index
+checkidx.dialog.title=Check index
+checkidx.label.index_path=Index directory path:
+checkidx.label.results=Results:
+checkidx.label.note=Note: Fully checking a large index takes long time.
+checkidx.label.warn=WARN: this writes a new segments file into the index, effectively removing all documents in broken segments from the index. BE CAREFUL.
+checkidx.button.check=Check Index
+checkidx.button.fix=Try to Repair
+# Overview
+overview.label.index_path=Index Path: 
+overview.label.num_fields=Number of Fields: 
+overview.label.num_docs=Number of Documents: 
+overview.label.num_terms=Number of Terms: 
+overview.label.del_opt=Has deletions? / Optimized?: 
+overview.label.index_version=Index Version: 
+overview.label.index_format=Index Format: 
+overview.label.dir_impl=Directory implementation: 
+overview.label.commit_point=Currently opened commit point: 
+overview.label.commit_userdata=Current commit user data: 
+overview.label.select_fields=Select a field from the list below, and press button to view top terms in the field.
+overview.label.available_fields=Available fields and term counts per field:
+overview.label.selected_field=Selected field:
+overview.label.num_top_terms=Num of terms:
+overview.label.top_terms=Top ranking terms: (Double-click for more options.)
+overview.button.show_terms=Show top terms >
+overview.toptermtable.menu.item1=Browse docs by this term
+overview.toptermtable.menu.item2=Search docs by this term
+# Documents
+documents.label.browse_doc_by_idx=Browse documents by Doc #
+documents.label.browse_terms=Browse terms in field:
+documents.label.browse_terms_hint=<html><p>Hint: <br> Edit the text field above and press Enter to seek to <br> arbitrary terms.<p></html>
+documents.label.browse_doc_by_term=Browse documents by term:
+documents.label.doc_num=Document #
+documents.label.doc_table_note1=(Select a row and double-click for more options.)
+documents.label.doc_table_note2=(To copy all or arbitrary field value(s), unselect all rows or select row(s), and click 'Copy values' button.)
+documents.button.add=Add document
+documents.button.first_term=First Term
+documents.button.first_termdoc=First Doc
+documents.button.next=Next
+documents.buttont.copy_values=Copy values
+documents.button.mlt=More like this
+documents.doctable.menu.item1=Show term vector
+documents.doctable.menu.item2=Show doc values
+documents.doctable.menu.item3=Show stored value
+documents.doctable.menu.item4=Copy stored value to clipboard
+documents.termvector.label.term_vector=Term vector for field:
+documents.termvector.message.not_available=Term vector for {0} field in doc #{1} not available.
+documents.docvalues.label.doc_values=Doc values for field:
+documents.docvalues.label.type=Doc values type:
+documents.docvalues.message.not_available=Doc values for {0} field  in doc #{1} not available.
+documents.stored.label.stored_value=Stored value for field:
+documents.stored.message.not_availabe=Stored value for {0} field  in doc #{1} not available.
+documents.field.message.not_selected=Field not selected.
+documents.termdocs.message.not_available=Next doc is not available.
+add_document.label.analyzer=Analyzer:
+add_document.hyperlink.change=> Change
+add_document.label.fields=Document fields
+add_document.info=Result will be showed here...
+add_document.button.add=Add
+add_document.message.success=Document successfully added and index re-opened! Close the dialog.
+add_document.message.fail=Some error occurred during writing new document...
+idx_options.label.index_options=Index options:
+idx_options.label.dv_type=DocValues type:
+idx_options.label.point_dims=Point dimensions:
+idx_options.label.point_dc=Dimension count:
+idx_options.label.point_nb=Dimension num bytes:
+idx_options.checkbox.stored=Stored
+idx_options.checkbox.tokenized=Tokenized
+idx_options.checkbox.omit_norm=Omit norms
+idx_options.checkbox.store_tv=Store term vectors
+idx_options.checkbox.store_tv_pos=positions
+idx_options.checkbox.store_tv_off=offsets
+idx_options.checkbox.store_tv_pay=payloads
+# Analysis
+analysis.label.config_dir=ConfigDir
+analysis.label.selected_analyzer=Selected Analyzer:
+analysis.label.show_chain=(Show analysis chain)
+analysis.radio.preset=Preset
+analysis.radio.custom=Custom
+analysis.button.browse=Browse
+analysis.button.build_analyzser=Build Analyzer
+analysis.button.test=Test Analyzer
+analysis.hyperlink.load_jars=Load external jars
+analysis.textarea.prompt=Apache Lucene is a high-performance, full-featured text search engine library.
+analysis.dialog.title.char_filter_params=CharFilter parameters
+analysis.dialog.title.selected_char_filter=Selected CharFilter
+analysis.dialog.title.token_filter_params=TokenFilter parameters
+analysis.dialog.title.selected_token_filter=Selected TokenFilters
+analysis.dialog.title.tokenizer_params=Tokenizer parameters
+analysis.dialog.hint.edit_param=Hint: Double click the row to show and edit parameters.
+analysis.dialog.chain.label.charfilters=Char Filters:
+analysis.dialog.chain.label.tokenizer=Tokenizer:
+analysis.dialog.chain.label.tokenfilters=Token Filters:
+analysis.message.build_success=Custom analyzer built successfully.
+analysis.message.empry_input=Please input text to analyze.
+analysis.hint.show_attributes=Hint: Double click the row to show all token attributes.
+analysis_preset.label.preset=Preset analyzers:
+analysis_custom.label.charfilters=Char Filters
+analysis_custom.label.tokenizer=Tokenizer
+analysis_custom.label.tokenfilters=Token Filters
+analysis_custom.label.selected=Selected
+analysis_custom.label.add=Add
+analysis_custom.label.set=Set
+analysis_custom.label.edit=Show & Edit
+# Search
+search.label.settings=Query settings
+search.label.expression=Query expression
+search.label.parsed=Parsed query
+search.label.results=Search Results:
+search.label.results.note=(Select a row and double-click for more options.)
+search.label.total=Total docs:
+search.button.parse=Parse
+search.button.mlt=More Like This
+search.button.search=Search
+search.button.del_all=Delete Docs
+search.checkbox.term=Term Query
+search.checkbox.rewrite=rewrite
+search.checkbox.exact_hits_cnt=exact hits count
+search.results.menu.explain=Explain
+search.results.menu.showdoc=Show all fields
+search.message.delete_confirm=Are you sure to permanently delete the documents?
+search.message.delete_success=Documents were deleted by query "{0}".
+search_parser.label.df=Default field
+search_parser.label.dop=Default operator
+search_parser.label.phrase_query=Phrase query:
+search_parser.label.phrase_slop=Phrase slop
+search_parser.label.fuzzy_query=Fuzzy query:
+search_parser.label.fuzzy_minsim=Minimal similarity
+search_parser.label.fuzzy_preflen=Prefix Length
+search_parser.label.daterange_query=Date range query:
+search_parser.label.date_res=Date resolution
+search_parser.label.locale=Locale
+search_parser.label.timezone=TimeZone
+search_parser.label.pointrange_query=Point range query:
+search_parser.label.pointrange_hint=(Hint: Click 'Numeric Type' cell and select proper type.)
+search_parser.checkbox.pos_incr=Enable position increments
+search_parser.checkbox.lead_wildcard=Allow leading wildcard (*)
+search_parser.checkbox.split_ws=Split on whitespace
+search_parser.checkbox.gen_pq=Generate phrase query
+search_parser.checkbox.gen_mts=Generate multi term synonyms phrase query
+search_analyzer.label.name=Name:
+search_analyzer.label.chain=Analysis chain
+search_analyzer.label.charfilters=Char Filters:
+search_analyzer.label.tokenizer=Tokenizer:
+search_analyzer.label.tokenfilters=Token Filters:
+search_analyzer.hyperlink.change=> Change
+search_similarity.label.bm25_params=BM25Similarity parameters:
+search_similarity.checkbox.use_classic=Use classic (TFIDF) similarity
+search_similarity.checkbox.discount_overlaps=Discount overlaps
+search_sort.label.primary=Primary sort:
+search_sort.label.secondary=Secondary sort:
+search_sort.label.field=Field
+search_sort.label.type=Type
+search_sort.label.order=Order
+search_values.label.description=Check fields to be loaded.
+search_values.checkbox.load_all=Load all available field values
+search_mlt.label.description=Check field names to be used when generating MLTQuery.
+search_mlt.label.max_doc_freq=Maximum document frequency:
+search_mlt.label.min_doc_freq=Minimum document frequency:
+serach_mlt.label.min_term_freq=Minimum term frequency:
+search_mlt.label.analyzer=Analyzer:
+search_mlt.hyperlink.change=> Change
+search_mlt.checkbox.select_all=Select all fields.
+search.explanation.description=Explanation for the document #
+# Commits
+commits.label.commit_points=Commit points
+commits.label.select_gen=Select generation:
+commits.label.deleted=Deleted:
+commits.label.segcount=Segments count:
+commits.label.userdata=User data:
+commits.label.files=Files
+commits.label.segments=Segments (click rows for more details)
+commits.label.segdetails=Segment details
+# Logs
+logs.label.see_also=See also:
+# Help dialogs
+help.fieldtype.TextField=A field that is indexed and tokenized, without term vectors.\n\n(Example Values)\n- Hello Lucene!
+help.fieldtype.StringField=A field that is indexed but not tokenized: the entire String value is indexed as a single token.\n\n(Example Values)\n- Java
+help.fieldtype.IntPoint=An indexed int field for fast range filters.\nIf you also need to store the value, you should add a separate StoredField instance.\nFinding all documents within an N-dimensional shape or range at search time is efficient. Multiple values for the same field in one document is allowed.\n\n(Example Values)\n- 1\n- 1,2,3\n\nFor multi dimensional data, comma-separated values are allowed.
+help.fieldtype.LongPoint=An indexed long field for fast range filters.\nIf you also need to store the value, you should add a separate StoredField instance.\nFinding all documents within an N-dimensional shape or range at search time is efficient. Multiple values for the same field in one document is allowed.\n\n(Example Values)\n- 1\n- 1,2,3\n\nFor multi dimensional data, comma-separated values are allowed.
+help.fieldtype.FloatPoint=An indexed float field for fast range filters.\nIf you also need to store the value, you should add a separate StoredField instance.\nFinding all documents within an N-dimensional shape or range at search time is efficient. Multiple values for the same field in one document is allowed.\n\n(Example Values)\n- 1.0\n- 42,3.14,2.718\n\nFor multi dimensional data, comma-separated values are allowed.
+help.fieldtype.DoublePoint=An indexed double field for fast range filters.\nIf you also need to store the value, you should add a separate StoredField instance.\nFinding all documents within an N-dimensional shape or range at search time is efficient. Multiple values for the same field in one document is allowed.\n\n(Example Values)\n- 1.0\n- 42,3.14,2.718\n\nFor multi dimensional data, comma-separated values are allowed.
+help.fieldtype.SortedDocValuesField=Field that stores a per-document BytesRef value, indexed for sorting.\nIf you also need to store the value, you should add a separate StoredField instance.\n\n(Example Values)\n- ID1234
+help.fieldtype.SortedSetDocValuesField=Field that stores a set of per-document BytesRef values, indexed for faceting,grouping,joining.\nIf you also need to store the value, you should add a separate StoredField instance.\n\n(Example Values)\n- red\n- blue
+help.fieldtype.NumericDocValuesField=Field that stores a per-document long value for scoring, sorting or value retrieval.\nIf you also need to store the value, you should add a separate StoredField instance.\nDoubles or Floats will be encoded with org.apache.lucene.util.NumericUtils.\n\n(Example Values)\n- 42\n- 3.14
+help.fieldtype.SortedNumericDocValuesField=Field that stores a per-document long values for scoring, sorting or value retrieval.\nIf you also need to store the value, you should add a separate StoredField instance.\nDoubles or Floats will be encoded with org.apache.lucene.util.NumericUtils.\n\n(Example Values)\n- 42\n- 3.14
+help.fieldtype.StoredField=A field whose value is stored.\n\n(Example Values)\n- Hello Lucene!
+help.fieldtype.Field=Expert: directly create a field for a document. Most users should use one of the sugar subclasses above.
\ No newline at end of file
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileTest.java b/lucene/luke/src/test/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileTest.java
new file mode 100644
index 00000000000..c345800e53e
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/app/desktop/util/inifile/SimpleIniFileTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.app.desktop.util.inifile;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.Test;
+
+public class SimpleIniFileTest extends LuceneTestCase {
+
+  @Test
+  public void testStore() throws IOException {
+    Path path = saveTestIni();
+    assertTrue(Files.exists(path));
+    assertTrue(Files.isRegularFile(path));
+
+    try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
+      List<String> lines = br.lines().collect(Collectors.toList());
+      assertEquals(8, lines.size());
+      assertEquals("[section1]", lines.get(0));
+      assertEquals("s1 = aaa", lines.get(1));
+      assertEquals("s2 = bbb", lines.get(2));
+      assertEquals("", lines.get(3));
+      assertEquals("[section2]", lines.get(4));
+      assertEquals("b1 = true", lines.get(5));
+      assertEquals("b2 = false", lines.get(6));
+      assertEquals("", lines.get(7));
+    }
+  }
+
+  @Test
+  public void testLoad() throws IOException {
+    Path path = saveTestIni();
+
+    SimpleIniFile iniFile = new SimpleIniFile();
+    iniFile.load(path);
+
+    Map<String, OptionMap> sections = iniFile.getSections();
+    assertEquals(2, sections.size());
+    assertEquals(2, sections.get("section1").size());
+    assertEquals(2, sections.get("section2").size());
+  }
+
+  @Test
+  public void testPut() {
+    SimpleIniFile iniFile = new SimpleIniFile();
+    iniFile.put("section1", "s1", "aaa");
+    iniFile.put("section1", "s1", "aaa_updated");
+    iniFile.put("section2", "b1", true);
+    iniFile.put("section2", "b2", null);
+
+    Map<String, OptionMap> sections = iniFile.getSections();
+    assertEquals("aaa_updated", sections.get("section1").get("s1"));
+    assertEquals("true", sections.get("section2").get("b1"));
+    assertNull(sections.get("section2").get("b2"));
+  }
+
+  @Test
+  public void testGet() throws IOException {
+    Path path = saveTestIni();
+    SimpleIniFile iniFile = new SimpleIniFile();
+    iniFile.load(path);
+
+    assertNull(iniFile.getString("", ""));
+
+    assertEquals("aaa", iniFile.getString("section1", "s1"));
+    assertEquals("bbb", iniFile.getString("section1", "s2"));
+    assertNull(iniFile.getString("section1", "s3"));
+    assertNull(iniFile.getString("section1", ""));
+
+    assertEquals(true, iniFile.getBoolean("section2", "b1"));
+    assertEquals(false, iniFile.getBoolean("section2", "b2"));
+    assertFalse(iniFile.getBoolean("section2", "b3"));
+  }
+
+  private Path saveTestIni() throws IOException {
+    SimpleIniFile iniFile = new SimpleIniFile();
+    iniFile.put("", "s0", "000");
+
+    iniFile.put("section1", "s1", "aaa");
+    iniFile.put("section1", "s2", "---");
+    iniFile.put("section1", "s2", "bbb");
+    iniFile.put("section1", "", "ccc");
+
+    iniFile.put("section2", "b1", true);
+    iniFile.put("section2", "b2", false);
+
+    Path path = createTempFile();
+    iniFile.store(path);
+    return path;
+  }
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/analysis/AnalysisImplTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/analysis/AnalysisImplTest.java
new file mode 100644
index 00000000000..39e8eca1e78
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/analysis/AnalysisImplTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.analysis;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.analysis.custom.CustomAnalyzer;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.Test;
+
+public class AnalysisImplTest extends LuceneTestCase {
+
+  @Test
+  public void testGetPresetAnalyzerTypes() throws Exception {
+    AnalysisImpl analysis = new AnalysisImpl();
+    Collection<Class<? extends Analyzer>> analyerTypes = analysis.getPresetAnalyzerTypes();
+    assertNotNull(analyerTypes);
+    for (Class<? extends Analyzer> clazz : analyerTypes) {
+      clazz.newInstance();
+    }
+  }
+
+  @Test
+  public void testGetAvailableCharFilters() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    Collection<String> charFilters = analysis.getAvailableCharFilters();
+    assertNotNull(charFilters);
+  }
+
+  @Test
+  public void testGetAvailableTokenizers() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    Collection<String> tokenizers = analysis.getAvailableTokenizers();
+    assertNotNull(tokenizers);
+  }
+
+  @Test
+  public void testGetAvailableTokenFilters() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    Collection<String> tokenFilters = analysis.getAvailableTokenFilters();
+    assertNotNull(tokenFilters);
+  }
+
+  @Test
+  public void testAnalyze_preset() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    String analyzerType = "org.apache.lucene.analysis.standard.StandardAnalyzer";
+    Analyzer analyzer = analysis.createAnalyzerFromClassName(analyzerType);
+    assertEquals(analyzerType, analyzer.getClass().getName());
+
+    String text = "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.";
+    List<Analysis.Token> tokens = analysis.analyze(text);
+    assertNotNull(tokens);
+  }
+
+  @Test
+  public void testAnalyze_custom() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    Map<String, String> tkParams = new HashMap<>();
+    tkParams.put("maxTokenLen", "128");
+    CustomAnalyzerConfig.Builder builder = new CustomAnalyzerConfig.Builder(
+        "keyword", tkParams)
+        .addTokenFilterConfig("lowercase", Collections.emptyMap());
+    CustomAnalyzer analyzer = (CustomAnalyzer) analysis.buildCustomAnalyzer(builder.build());
+    assertEquals("org.apache.lucene.analysis.custom.CustomAnalyzer", analyzer.getClass().getName());
+    assertEquals("org.apache.lucene.analysis.core.KeywordTokenizerFactory", analyzer.getTokenizerFactory().getClass().getName());
+    assertEquals("org.apache.lucene.analysis.core.LowerCaseFilterFactory", analyzer.getTokenFilterFactories().get(0).getClass().getName());
+
+    String text = "Apache Lucene";
+    List<Analysis.Token> tokens = analysis.analyze(text);
+    assertNotNull(tokens);
+  }
+
+  @Test
+  public void testAnalyzer_custom_with_confdir() throws Exception {
+    Path confDir = createTempDir("conf");
+    Path stopFile = Files.createFile(Paths.get(confDir.toString(), "stop.txt"));
+    Files.write(stopFile, "of\nthe\nby\nfor\n".getBytes(StandardCharsets.UTF_8));
+
+    AnalysisImpl analysis = new AnalysisImpl();
+    Map<String, String> tkParams = new HashMap<>();
+    tkParams.put("maxTokenLen", "128");
+    Map<String, String> tfParams = new HashMap<>();
+    tfParams.put("ignoreCase", "true");
+    tfParams.put("words", "stop.txt");
+    tfParams.put("format", "wordset");
+    CustomAnalyzerConfig.Builder builder = new CustomAnalyzerConfig.Builder(
+        "whitespace", tkParams)
+        .configDir(confDir.toString())
+        .addTokenFilterConfig("lowercase", Collections.emptyMap())
+        .addTokenFilterConfig("stop", tfParams);
+    CustomAnalyzer analyzer = (CustomAnalyzer) analysis.buildCustomAnalyzer(builder.build());
+    assertEquals("org.apache.lucene.analysis.custom.CustomAnalyzer", analyzer.getClass().getName());
+    assertEquals("org.apache.lucene.analysis.core.WhitespaceTokenizerFactory", analyzer.getTokenizerFactory().getClass().getName());
+    assertEquals("org.apache.lucene.analysis.core.LowerCaseFilterFactory", analyzer.getTokenFilterFactories().get(0).getClass().getName());
+    assertEquals("org.apache.lucene.analysis.core.StopFilterFactory", analyzer.getTokenFilterFactories().get(1).getClass().getName());
+
+    String text = "Government of the People, by the People, for the People";
+    List<Analysis.Token> tokens = analysis.analyze(text);
+    assertNotNull(tokens);
+  }
+
+  @Test(expected = LukeException.class)
+  public void testAnalyze_not_set() {
+    AnalysisImpl analysis = new AnalysisImpl();
+    String text = "This test must fail.";
+    analysis.analyze(text);
+  }
+
+
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/commits/CommitsImplTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/commits/CommitsImplTest.java
new file mode 100644
index 00000000000..7e968d26fa2
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/commits/CommitsImplTest.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.commits;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.codecs.Codec;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.NoDeletionPolicy;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+// See: https://github.com/DmitryKey/luke/issues/111
+@LuceneTestCase.SuppressCodecs({
+    "DummyCompressingStoredFields", "HighCompressionCompressingStoredFields", "FastCompressingStoredFields", "FastDecompressionCompressingStoredFields"
+})
+public class CommitsImplTest extends LuceneTestCase {
+
+  private DirectoryReader reader;
+
+  private Directory dir;
+
+  private Path indexDir;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    indexDir = createIndex();
+    dir = newFSDirectory(indexDir);
+    reader = DirectoryReader.open(dir);
+  }
+
+  private Path createIndex() throws IOException {
+    Path indexDir = createTempDir();
+
+    Directory dir = newFSDirectory(indexDir);
+
+    IndexWriterConfig config = new IndexWriterConfig(new MockAnalyzer(random()));
+    config.setIndexDeletionPolicy(NoDeletionPolicy.INSTANCE);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, config);
+
+    Document doc1 = new Document();
+    doc1.add(newStringField("f1", "1", Field.Store.NO));
+    writer.addDocument(doc1);
+
+    writer.commit();
+
+    Document doc2 = new Document();
+    doc2.add(newStringField("f1", "2", Field.Store.NO));
+    writer.addDocument(doc2);
+
+    Document doc3 = new Document();
+    doc3.add(newStringField("f1", "3", Field.Store.NO));
+    writer.addDocument(doc3);
+
+    writer.commit();
+
+    writer.close();
+    dir.close();
+
+    return indexDir;
+  }
+
+  @Override
+  @After
+  public void tearDown() throws Exception {
+    super.tearDown();
+    reader.close();
+    dir.close();
+  }
+
+  @Test
+  public void testListCommits() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    List<Commit> commitList = commits.listCommits();
+    assertTrue(commitList.size() > 0);
+    // should be sorted by descending order in generation
+    assertEquals(commitList.size(), commitList.get(0).getGeneration());
+    assertEquals(1, commitList.get(commitList.size()-1).getGeneration());
+  }
+
+  @Test
+  public void testGetCommit() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Optional<Commit> commit = commits.getCommit(1);
+    assertTrue(commit.isPresent());
+    assertEquals(1, commit.get().getGeneration());
+  }
+
+  @Test
+  public void testGetCommit_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    assertFalse(commits.getCommit(10).isPresent());
+  }
+
+  @Test
+  public void testGetFiles() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    List<File> files = commits.getFiles(1);
+    assertTrue(files.size() > 0);
+    assertTrue(files.stream().anyMatch(file -> file.getFileName().equals("segments_1")));
+  }
+
+  @Test
+  public void testGetFiles_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    assertTrue(commits.getFiles(10).isEmpty());
+  }
+
+  @Test
+  public void testGetSegments() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    List<Segment> segments = commits.getSegments(1);
+    assertTrue(segments.size() > 0);
+  }
+
+  @Test
+  public void testGetSegments_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    assertTrue(commits.getSegments(10).isEmpty());
+  }
+
+  @Test
+  public void testGetSegmentAttributes() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Map<String, String> attributes = commits.getSegmentAttributes(1, "_0");
+    assertTrue(attributes.size() > 0);
+  }
+
+  @Test
+  public void testGetSegmentAttributes_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Map<String, String> attributes = commits.getSegmentAttributes(3, "_0");
+    assertTrue(attributes.isEmpty());
+  }
+
+  @Test
+  public void testGetSegmentAttributes_invalid_name() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Map<String, String> attributes = commits.getSegmentAttributes(1, "xxx");
+    assertTrue(attributes.isEmpty());
+  }
+
+  @Test
+  public void testGetSegmentDiagnostics() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Map<String, String> diagnostics = commits.getSegmentDiagnostics(1, "_0");
+    assertTrue(diagnostics.size() > 0);
+  }
+
+  @Test
+  public void testGetSegmentDiagnostics_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    assertTrue(commits.getSegmentDiagnostics(10, "_0").isEmpty());
+  }
+
+
+  @Test
+  public void testGetSegmentDiagnostics_invalid_name() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Map<String, String> diagnostics = commits.getSegmentDiagnostics(1,"xxx");
+    assertTrue(diagnostics.isEmpty());
+  }
+
+  @Test
+  public void testSegmentCodec() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Optional<Codec> codec = commits.getSegmentCodec(1, "_0");
+    assertTrue(codec.isPresent());
+  }
+
+  @Test
+  public void testSegmentCodec_generation_notfound() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Optional<Codec> codec = commits.getSegmentCodec(10, "_0");
+    assertFalse(codec.isPresent());
+  }
+
+  @Test
+  public void testSegmentCodec_invalid_name() {
+    CommitsImpl commits = new CommitsImpl(reader, indexDir.toString());
+    Optional<Codec> codec = commits.getSegmentCodec(1, "xxx");
+    assertFalse(codec.isPresent());
+
+  }
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocValuesAdapterTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocValuesAdapterTest.java
new file mode 100644
index 00000000000..e6349bf1e9d
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocValuesAdapterTest.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.document.BinaryDocValuesField;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedDocValuesField;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.BytesRef;
+import org.junit.Test;
+
+public class DocValuesAdapterTest extends DocumentsTestBase {
+
+  @Override
+  protected void createIndex() throws IOException {
+    indexDir = createTempDir("testIndex");
+
+    Directory dir = newFSDirectory(indexDir);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, new MockAnalyzer(random()));
+
+    Document doc = new Document();
+    doc.add(new BinaryDocValuesField("dv_binary", new BytesRef("lucene")));
+    doc.add(new SortedDocValuesField("dv_sorted", new BytesRef("abc")));
+    doc.add(new SortedSetDocValuesField("dv_sortedset", new BytesRef("python")));
+    doc.add(new SortedSetDocValuesField("dv_sortedset", new BytesRef("java")));
+    doc.add(new NumericDocValuesField("dv_numeric", 42L));
+    doc.add(new SortedNumericDocValuesField("dv_sortednumeric", 22L));
+    doc.add(new SortedNumericDocValuesField("dv_sortednumeric", 11L));
+    doc.add(newStringField("no_dv", "aaa", Field.Store.NO));
+    writer.addDocument(doc);
+
+    writer.commit();
+    writer.close();
+    dir.close();
+  }
+
+  @Test
+  public void testGetDocValues_binary() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    DocValues values = adapterImpl.getDocValues(0, "dv_binary").orElseThrow(IllegalStateException::new);
+    assertEquals(DocValuesType.BINARY, values.getDvType());
+    assertEquals(new BytesRef("lucene"), values.getValues().get(0));
+    assertEquals(Collections.emptyList(), values.getNumericValues());
+  }
+
+  @Test
+  public void testGetDocValues_sorted() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    DocValues values = adapterImpl.getDocValues(0, "dv_sorted").orElseThrow(IllegalStateException::new);
+    assertEquals(DocValuesType.SORTED, values.getDvType());
+    assertEquals(new BytesRef("abc"), values.getValues().get(0));
+    assertEquals(Collections.emptyList(), values.getNumericValues());
+  }
+
+  @Test
+  public void testGetDocValues_sorted_set() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    DocValues values = adapterImpl.getDocValues(0, "dv_sortedset").orElseThrow(IllegalStateException::new);
+    assertEquals(DocValuesType.SORTED_SET, values.getDvType());
+    assertEquals(new BytesRef("java"), values.getValues().get(0));
+    assertEquals(new BytesRef("python"), values.getValues().get(1));
+    assertEquals(Collections.emptyList(), values.getNumericValues());
+  }
+
+  @Test
+  public void testGetDocValues_numeric() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    DocValues values = adapterImpl.getDocValues(0, "dv_numeric").orElseThrow(IllegalStateException::new);
+    assertEquals(DocValuesType.NUMERIC, values.getDvType());
+    assertEquals(Collections.emptyList(), values.getValues());
+    assertEquals(42L, values.getNumericValues().get(0).longValue());
+  }
+
+  @Test
+  public void testGetDocValues_sorted_numeric() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    DocValues values = adapterImpl.getDocValues(0, "dv_sortednumeric").orElseThrow(IllegalStateException::new);
+    assertEquals(DocValuesType.SORTED_NUMERIC, values.getDvType());
+    assertEquals(Collections.emptyList(), values.getValues());
+    assertEquals(11L, values.getNumericValues().get(0).longValue());
+    assertEquals(22L, values.getNumericValues().get(1).longValue());
+  }
+
+  @Test
+  public void testGetDocValues_notAvailable() throws Exception {
+    DocValuesAdapter adapterImpl = new DocValuesAdapter(reader);
+    assertFalse(adapterImpl.getDocValues(0, "no_dv").isPresent());
+  }
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsImplTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsImplTest.java
new file mode 100644
index 00000000000..730d251949c
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsImplTest.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.util.List;
+
+import org.apache.lucene.index.DocValuesType;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.luke.models.util.IndexUtils;
+import org.apache.lucene.store.AlreadyClosedException;
+import org.apache.lucene.util.LuceneTestCase;
+import org.apache.lucene.util.NumericUtils;
+import org.junit.Test;
+
+
+// See: https://github.com/DmitryKey/luke/issues/133
+@LuceneTestCase.SuppressCodecs({
+    "DummyCompressingStoredFields", "HighCompressionCompressingStoredFields", "FastCompressingStoredFields", "FastDecompressionCompressingStoredFields"
+})
+public class DocumentsImplTest extends DocumentsTestBase {
+
+  @Test
+  public void testGetMaxDoc() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertEquals(5, documents.getMaxDoc());
+  }
+
+  @Test
+  public void testIsLive() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertTrue(documents.isLive(0));
+  }
+
+  @Test
+  public void testGetDocumentFields() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    List<DocumentField> fields = documents.getDocumentFields(0);
+    assertEquals(5, fields.size());
+
+    DocumentField f1 = fields.get(0);
+    assertEquals("title", f1.getName());
+    assertEquals(IndexOptions.DOCS_AND_FREQS, f1.getIdxOptions());
+    assertFalse(f1.hasTermVectors());
+    assertFalse(f1.hasPayloads());
+    assertFalse(f1.hasNorms());
+    assertEquals(0, f1.getNorm());
+    assertTrue(f1.isStored());
+    assertEquals("Pride and Prejudice", f1.getStringValue());
+    assertNull(f1.getBinaryValue());
+    assertNull(f1.getNumericValue());
+    assertEquals(DocValuesType.NONE, f1.getDvType());
+    assertEquals(0, f1.getPointDimensionCount());
+    assertEquals(0, f1.getPointNumBytes());
+
+    DocumentField f2 = fields.get(1);
+    assertEquals("author", f2.getName());
+    assertEquals(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS, f2.getIdxOptions());
+    assertFalse(f2.hasTermVectors());
+    assertFalse(f2.hasPayloads());
+    assertTrue(f2.hasNorms());
+    assertTrue(f2.getNorm() > 0);
+    assertTrue(f2.isStored());
+    assertEquals("Jane Austen", f2.getStringValue());
+    assertNull(f2.getBinaryValue());
+    assertNull(f2.getNumericValue());
+    assertEquals(DocValuesType.NONE, f2.getDvType());
+    assertEquals(0, f2.getPointDimensionCount());
+    assertEquals(0, f2.getPointNumBytes());
+
+    DocumentField f3 = fields.get(2);
+    assertEquals("text", f3.getName());
+    assertEquals(IndexOptions.DOCS_AND_FREQS, f3.getIdxOptions());
+    assertTrue(f3.hasTermVectors());
+    assertFalse(f3.hasPayloads());
+    assertTrue(f3.hasNorms());
+    assertTrue(f3.getNorm() > 0);
+    assertFalse(f3.isStored());
+    assertNull(f3.getStringValue());
+    assertNull(f3.getBinaryValue());
+    assertNull(f3.getNumericValue());
+    assertEquals(DocValuesType.NONE, f3.getDvType());
+    assertEquals(0, f3.getPointDimensionCount());
+    assertEquals(0, f3.getPointNumBytes());
+
+    DocumentField f4 = fields.get(3);
+    assertEquals("subject", f4.getName());
+    assertEquals(IndexOptions.NONE, f4.getIdxOptions());
+    assertFalse(f4.hasTermVectors());
+    assertFalse(f4.hasPayloads());
+    assertFalse(f4.hasNorms());
+    assertEquals(0, f4.getNorm());
+    assertFalse(f4.isStored());
+    assertNull(f4.getStringValue());
+    assertNull(f4.getBinaryValue());
+    assertNull(f4.getNumericValue());
+    assertEquals(DocValuesType.SORTED_SET, f4.getDvType());
+    assertEquals(0, f4.getPointDimensionCount());
+    assertEquals(0, f4.getPointNumBytes());
+
+    DocumentField f5 = fields.get(4);
+    assertEquals("downloads", f5.getName());
+    assertEquals(IndexOptions.NONE, f5.getIdxOptions());
+    assertFalse(f5.hasTermVectors());
+    assertFalse(f5.hasPayloads());
+    assertFalse(f5.hasNorms());
+    assertEquals(0, f5.getNorm());
+    assertTrue(f5.isStored());
+    assertNull(f5.getStringValue());
+    assertEquals(28533, NumericUtils.sortableBytesToInt(f5.getBinaryValue().bytes, 0));
+    assertNull(f5.getNumericValue());
+  }
+
+  @Test
+  public void testFirstTerm() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    assertEquals("title", documents.getCurrentField());
+    assertEquals("a", term.text());
+  }
+
+  @Test
+  public void testFirstTerm_notAvailable() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertFalse(documents.firstTerm("subject").isPresent());
+    assertNull(documents.getCurrentField());
+  }
+
+  @Test
+  public void testNextTerm() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    Term term = documents.nextTerm().orElseThrow(IllegalStateException::new);
+    assertEquals("adventures", term.text());
+
+    while (documents.nextTerm().isPresent()) {
+      Integer freq = documents.getDocFreq().orElseThrow(IllegalStateException::new);
+    }
+  }
+
+  @Test
+  public void testNextTerm_unPositioned() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertFalse(documents.nextTerm().isPresent());
+  }
+
+  @Test
+  public void testSeekTerm() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    Term term = documents.seekTerm("pri").orElseThrow(IllegalStateException::new);
+    assertEquals("pride", term.text());
+
+    assertFalse(documents.seekTerm("x").isPresent());
+  }
+
+  @Test
+  public void testSeekTerm_unPositioned() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertFalse(documents.seekTerm("a").isPresent());
+  }
+
+  @Test
+  public void testFirstTermDoc() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    Term term = documents.seekTerm("adv").orElseThrow(IllegalStateException::new);
+    assertEquals("adventures", term.text());
+    int docid = documents.firstTermDoc().orElseThrow(IllegalStateException::new);
+    assertEquals(1, docid);
+  }
+
+  @Test
+  public void testFirstTermDoc_unPositioned() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    assertFalse(documents.firstTermDoc().isPresent());
+  }
+
+  @Test
+  public void testNextTermDoc() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    term = documents.seekTerm("adv").orElseThrow(IllegalStateException::new);
+    assertEquals("adventures", term.text());
+    int docid = documents.firstTermDoc().orElseThrow(IllegalStateException::new);
+    docid = documents.nextTermDoc().orElseThrow(IllegalStateException::new);
+    assertEquals(4, docid);
+
+    assertFalse(documents.nextTermDoc().isPresent());
+  }
+
+  @Test
+  public void testNextTermDoc_unPositioned() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    assertFalse(documents.nextTermDoc().isPresent());
+  }
+
+  @Test
+  public void testTermPositions() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("author").orElseThrow(IllegalStateException::new);
+    term = documents.seekTerm("carroll").orElseThrow(IllegalStateException::new);
+    int docid = documents.firstTermDoc().orElseThrow(IllegalStateException::new);
+    List<TermPosting> postings = documents.getTermPositions();
+    assertEquals(1, postings.size());
+    assertEquals(1, postings.get(0).getPosition());
+    assertEquals(6, postings.get(0).getStartOffset());
+    assertEquals(13, postings.get(0).getEndOffset());
+  }
+
+  @Test
+  public void testTermPositions_unPositioned() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("author").orElseThrow(IllegalStateException::new);
+    assertEquals(0, documents.getTermPositions().size());
+  }
+
+  @Test
+  public void testTermPositions_noPositions() {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    Term term = documents.firstTerm("title").orElseThrow(IllegalStateException::new);
+    int docid = documents.firstTermDoc().orElseThrow(IllegalStateException::new);
+    assertEquals(0, documents.getTermPositions().size());
+  }
+
+  @Test(expected = AlreadyClosedException.class)
+  public void testClose() throws Exception {
+    DocumentsImpl documents = new DocumentsImpl(reader);
+    reader.close();
+    IndexUtils.getFieldNames(reader);
+  }
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsTestBase.java b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsTestBase.java
new file mode 100644
index 00000000000..58519fa90ed
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/DocumentsTestBase.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.LuceneTestCase;
+import org.apache.lucene.util.NumericUtils;
+import org.junit.After;
+import org.junit.Before;
+
+public abstract class DocumentsTestBase extends LuceneTestCase {
+  protected IndexReader reader;
+  protected Directory dir;
+  protected Path indexDir;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    createIndex();
+    dir = newFSDirectory(indexDir);
+    reader = DirectoryReader.open(dir);
+  }
+
+  protected void createIndex() throws IOException {
+    indexDir = createTempDir();
+
+    Directory dir = newFSDirectory(indexDir);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, new StandardAnalyzer());
+
+    FieldType titleType = new FieldType();
+    titleType.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
+    titleType.setStored(true);
+    titleType.setTokenized(true);
+    titleType.setOmitNorms(true);
+
+    FieldType authorType = new FieldType();
+    authorType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
+    authorType.setStored(true);
+    authorType.setTokenized(true);
+    authorType.setOmitNorms(false);
+
+    FieldType textType = new FieldType();
+    textType.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
+    textType.setStored(false);
+    textType.setTokenized(true);
+    textType.setStoreTermVectors(true);
+    textType.setOmitNorms(false);
+
+    FieldType downloadsType = new FieldType();
+    downloadsType.setDimensions(1, Integer.BYTES);
+    downloadsType.setStored(true);
+
+    Document doc1 = new Document();
+    doc1.add(newField("title", "Pride and Prejudice", titleType));
+    doc1.add(newField("author", "Jane Austen", authorType));
+    doc1.add(newField("text",
+        "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.",
+        textType));
+    doc1.add(new SortedSetDocValuesField("subject", new BytesRef("Fiction")));
+    doc1.add(new SortedSetDocValuesField("subject", new BytesRef("Love stories")));
+    doc1.add(new Field("downloads", packInt(28533), downloadsType));
+    writer.addDocument(doc1);
+
+    Document doc2 = new Document();
+    doc2.add(newField("title", "Alice's Adventures in Wonderland", titleType));
+    doc2.add(newField("author", "Lewis Carroll", authorType));
+    doc2.add(newField("text", "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, ‘and what is the use of a book,’ thought Alice ‘without pictures or conversations?’",
+        textType));
+    doc2.add(new SortedSetDocValuesField("subject", new BytesRef("Fantasy literature")));
+    doc2.add(new Field("downloads", packInt(18712), downloadsType));
+    writer.addDocument(doc2);
+
+    Document doc3 = new Document();
+    doc3.add(newField("title", "Frankenstein; Or, The Modern Prometheus", titleType));
+    doc3.add(newField("author", "Mary Wollstonecraft Shelley", authorType));
+    doc3.add(newField("text", "You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday, and my first task is to assure my dear sister of my welfare and increasing confidence in the success of my undertaking.",
+        textType));
+    doc3.add(new SortedSetDocValuesField("subject", new BytesRef("Science fiction")));
+    doc3.add(new SortedSetDocValuesField("subject", new BytesRef("Horror tales")));
+    doc3.add(new SortedSetDocValuesField("subject", new BytesRef("Monsters")));
+    doc3.add(new Field("downloads", packInt(14737), downloadsType));
+    writer.addDocument(doc3);
+
+    Document doc4 = new Document();
+    doc4.add(newField("title", "A Doll's House : a play", titleType));
+    doc4.add(newField("author", "Henrik Ibsen", authorType));
+    doc4.add(newField("text", "",
+        textType));
+    doc4.add(new SortedSetDocValuesField("subject", new BytesRef("Drama")));
+    doc4.add(new Field("downloads", packInt(14629), downloadsType));
+    writer.addDocument(doc4);
+
+    Document doc5 = new Document();
+    doc5.add(newField("title", "The Adventures of Sherlock Holmes", titleType));
+    doc5.add(newField("author", "Arthur Conan Doyle", authorType));
+    doc5.add(newField("text", "To Sherlock Holmes she is always the woman. I have seldom heard him mention her under any other name. In his eyes she eclipses and predominates the whole of her sex.",
+        textType));
+    doc5.add(new SortedSetDocValuesField("subject", new BytesRef("Fiction")));
+    doc5.add(new SortedSetDocValuesField("subject", new BytesRef("Detective and mystery stories")));
+    doc5.add(new Field("downloads", packInt(12828), downloadsType));
+    writer.addDocument(doc5);
+
+    writer.commit();
+
+    writer.close();
+    dir.close();
+  }
+
+  private BytesRef packInt(int value) {
+    byte[] dest = new byte[Integer.BYTES];
+    NumericUtils.intToSortableBytes(value, dest, 0);
+    return new BytesRef(dest);
+  }
+
+  @Override
+  @After
+  public void tearDown() throws Exception {
+    super.tearDown();
+    reader.close();
+    dir.close();
+  }
+
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/documents/TermVectorsAdapterTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/TermVectorsAdapterTest.java
new file mode 100644
index 00000000000..5d85e854bb0
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/documents/TermVectorsAdapterTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.documents;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.store.Directory;
+import org.junit.Test;
+
+public class TermVectorsAdapterTest extends DocumentsTestBase {
+
+  @Override
+  protected void createIndex() throws IOException {
+    indexDir = createTempDir("testIndex");
+
+    Directory dir = newFSDirectory(indexDir);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, new StandardAnalyzer());
+
+    FieldType textType = new FieldType();
+    textType.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
+    textType.setTokenized(true);
+    textType.setStoreTermVectors(true);
+
+    FieldType textType_pos = new FieldType();
+    textType_pos.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
+    textType_pos.setTokenized(true);
+    textType_pos.setStoreTermVectors(true);
+    textType_pos.setStoreTermVectorPositions(true);
+
+    FieldType textType_pos_offset = new FieldType();
+    textType_pos_offset.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
+    textType_pos_offset.setTokenized(true);
+    textType_pos_offset.setStoreTermVectors(true);
+    textType_pos_offset.setStoreTermVectorPositions(true);
+    textType_pos_offset.setStoreTermVectorOffsets(true);
+
+    String text = "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.";
+    Document doc = new Document();
+    doc.add(newField("text1", text, textType));
+    doc.add(newField("text2", text, textType_pos));
+    doc.add(newField("text3", text, textType_pos_offset));
+    writer.addDocument(doc);
+
+    writer.commit();
+    writer.close();
+    dir.close();
+  }
+
+  @Test
+  public void testGetTermVector() throws Exception {
+    TermVectorsAdapter adapterImpl = new TermVectorsAdapter(reader);
+    List<TermVectorEntry> tvEntries = adapterImpl.getTermVector(0, "text1");
+
+    assertEquals(18, tvEntries.size());
+
+    assertEquals("a", tvEntries.get(0).getTermText());
+    assertEquals(4, tvEntries.get(0).getFreq());
+
+    assertEquals("acknowledged", tvEntries.get(1).getTermText());
+    assertEquals(1, tvEntries.get(1).getFreq());
+
+    assertEquals("be", tvEntries.get(2).getTermText());
+    assertEquals(1, tvEntries.get(2).getFreq());
+
+    assertEquals("fortune", tvEntries.get(3).getTermText());
+    assertEquals(1, tvEntries.get(3).getFreq());
+
+    assertEquals("good", tvEntries.get(4).getTermText());
+    assertEquals(1, tvEntries.get(4).getFreq());
+
+    assertEquals("in", tvEntries.get(5).getTermText());
+    assertEquals(2, tvEntries.get(5).getFreq());
+
+    assertEquals("is", tvEntries.get(6).getTermText());
+    assertEquals(1, tvEntries.get(6).getFreq());
+
+    assertEquals("it", tvEntries.get(7).getTermText());
+    assertEquals(1, tvEntries.get(7).getFreq());
+
+    assertEquals("man", tvEntries.get(8).getTermText());
+    assertEquals(1, tvEntries.get(8).getFreq());
+
+    assertEquals("must", tvEntries.get(9).getTermText());
+    assertEquals(1, tvEntries.get(9).getFreq());
+
+    assertEquals("of", tvEntries.get(10).getTermText());
+    assertEquals(1, tvEntries.get(2).getFreq());
+
+    assertEquals("possession", tvEntries.get(11).getTermText());
+    assertEquals(1, tvEntries.get(11).getFreq());
+
+    assertEquals("single", tvEntries.get(12).getTermText());
+    assertEquals(1, tvEntries.get(12).getFreq());
+
+    assertEquals("that", tvEntries.get(13).getTermText());
+    assertEquals(1, tvEntries.get(13).getFreq());
+
+    assertEquals("truth", tvEntries.get(14).getTermText());
+    assertEquals(1, tvEntries.get(14).getFreq());
+
+    assertEquals("universally", tvEntries.get(15).getTermText());
+    assertEquals(1, tvEntries.get(15).getFreq());
+
+    assertEquals("want", tvEntries.get(16).getTermText());
+    assertEquals(1, tvEntries.get(16).getFreq());
+
+    assertEquals("wife", tvEntries.get(17).getTermText());
+    assertEquals(1, tvEntries.get(17).getFreq());
+  }
+
+  @Test
+  public void testGetTermVector_with_positions() throws Exception {
+    TermVectorsAdapter adapterImpl = new TermVectorsAdapter(reader);
+    List<TermVectorEntry> tvEntries = adapterImpl.getTermVector(0, "text2");
+
+    assertEquals(18, tvEntries.size());
+
+    assertEquals("acknowledged", tvEntries.get(1).getTermText());
+    assertEquals(1, tvEntries.get(1).getFreq());
+    assertEquals(5, tvEntries.get(1).getPositions().get(0).getPosition());
+    assertFalse(tvEntries.get(1).getPositions().get(0).getStartOffset().isPresent());
+    assertFalse(tvEntries.get(1).getPositions().get(0).getEndOffset().isPresent());
+  }
+
+  @Test
+  public void testGetTermVector_with_positions_offsets() throws Exception {
+    TermVectorsAdapter adapterImpl = new TermVectorsAdapter(reader);
+    List<TermVectorEntry> tvEntries = adapterImpl.getTermVector(0, "text3");
+
+    assertEquals(18, tvEntries.size());
+
+    assertEquals("acknowledged", tvEntries.get(1).getTermText());
+    assertEquals(1, tvEntries.get(1).getFreq());
+    assertEquals(5, tvEntries.get(1).getPositions().get(0).getPosition());
+    assertEquals(26, tvEntries.get(1).getPositions().get(0).getStartOffset().orElse(-1));
+    assertEquals(38, tvEntries.get(1).getPositions().get(0).getEndOffset().orElse(-1));
+  }
+
+  @Test
+  public void testGetTermVectors_notAvailable() throws Exception {
+    TermVectorsAdapter adapterImpl = new TermVectorsAdapter(reader);
+    assertEquals(0, adapterImpl.getTermVector(0, "title").size());
+  }
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewImplTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewImplTest.java
new file mode 100644
index 00000000000..6e4522b8156
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewImplTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.lucene.store.AlreadyClosedException;
+import org.junit.Test;
+
+public class OverviewImplTest extends OverviewTestBase {
+
+  @Test
+  public void testGetIndexPath() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(indexDir.toString(), overview.getIndexPath());
+  }
+
+  @Test
+  public void testGetNumFields() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(2, (long) overview.getNumFields());
+  }
+
+  @Test
+  public void testGetFieldNames() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(
+        new HashSet<>(Arrays.asList("f1", "f2")),
+        new HashSet<>(overview.getFieldNames()));
+  }
+
+  @Test
+  public void testGetNumDocuments() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(3, (long) overview.getNumDocuments());
+  }
+
+  @Test
+  public void testGetNumTerms() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(9, overview.getNumTerms());
+  }
+
+  @Test
+  public void testHasDeletions() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertFalse(overview.hasDeletions());
+  }
+
+  @Test
+  public void testGetNumDeletedDocs() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(0, (long) overview.getNumDeletedDocs());
+  }
+
+  @Test
+  public void testIsOptimized() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertTrue(overview.isOptimized().orElse(false));
+  }
+
+  @Test
+  public void testGetIndexVersion() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertTrue(overview.getIndexVersion().orElseThrow(IllegalStateException::new) > 0);
+  }
+
+  @Test
+  public void testGetIndexFormat() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals("Lucene 7.4 or later", overview.getIndexFormat().get());
+  }
+
+  @Test
+  public void testGetDirImpl() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertEquals(dir.getClass().getName(), overview.getDirImpl().get());
+  }
+
+  @Test
+  public void testGetCommitDescription() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertTrue(overview.getCommitDescription().isPresent());
+  }
+
+  @Test
+  public void testGetCommitUserData() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    assertTrue(overview.getCommitUserData().isPresent());
+  }
+
+  @Test
+  public void testGetSortedTermCounts() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    Map<String, Long>  countsMap = overview.getSortedTermCounts(TermCountsOrder.COUNT_DESC);
+    assertEquals(Arrays.asList("f2", "f1"), new ArrayList<>(countsMap.keySet()));
+  }
+
+  @Test
+  public void testGetTopTerms() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    List<TermStats> result = overview.getTopTerms("f2", 2);
+    assertEquals("a", result.get(0).getDecodedTermText());
+    assertEquals(3, result.get(0).getDocFreq());
+    assertEquals("f2", result.get(0).getField());
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testGetTopTerms_illegal_numterms() {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    overview.getTopTerms("f2", -1);
+  }
+
+  @Test(expected = AlreadyClosedException.class)
+  public void testClose() throws Exception {
+    OverviewImpl overview = new OverviewImpl(reader, indexDir.toString());
+    reader.close();
+    overview.getNumFields();
+  }
+
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewTestBase.java b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewTestBase.java
new file mode 100644
index 00000000000..5554d709941
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/OverviewTestBase.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.After;
+import org.junit.Before;
+
+public abstract class OverviewTestBase extends LuceneTestCase {
+
+  IndexReader reader;
+
+  Directory dir;
+
+  Path indexDir;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    indexDir = createIndex();
+    dir = newFSDirectory(indexDir);
+    reader = DirectoryReader.open(dir);
+  }
+
+  private Path createIndex() throws IOException {
+    Path indexDir = createTempDir();
+
+    Directory dir = newFSDirectory(indexDir);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, new MockAnalyzer(random()));
+
+    Document doc1 = new Document();
+    doc1.add(newStringField("f1", "1", Field.Store.NO));
+    doc1.add(newTextField("f2", "a b c d e", Field.Store.NO));
+    writer.addDocument(doc1);
+
+    Document doc2 = new Document();
+    doc2.add(newStringField("f1", "2", Field.Store.NO));
+    doc2.add(new TextField("f2", "a c", Field.Store.NO));
+    writer.addDocument(doc2);
+
+    Document doc3 = new Document();
+    doc3.add(newStringField("f1", "3", Field.Store.NO));
+    doc3.add(newTextField("f2", "a f", Field.Store.NO));
+    writer.addDocument(doc3);
+
+    Map<String, String> userData = new HashMap<>();
+    userData.put("data", "val");
+    writer.w.setLiveCommitData(userData.entrySet());
+
+    writer.commit();
+
+    writer.close();
+    dir.close();
+
+    return indexDir;
+  }
+
+  @Override
+  @After
+  public void tearDown() throws Exception {
+    super.tearDown();
+    reader.close();
+    dir.close();
+  }
+
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TermCountsTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TermCountsTest.java
new file mode 100644
index 00000000000..0ccfd5e67ce
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TermCountsTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Map;
+
+import org.junit.Test;
+
+public class TermCountsTest extends OverviewTestBase {
+
+  @Test
+  public void testNumTerms() throws Exception {
+    TermCounts termCounts = new TermCounts(reader);
+    assertEquals(9, termCounts.numTerms());
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testSortedTermCounts_count_asc() throws Exception {
+    TermCounts termCounts = new TermCounts(reader);
+
+    Map<String, Long> countsMap = termCounts.sortedTermCounts(TermCountsOrder.COUNT_ASC);
+    assertEquals(Arrays.asList("f1", "f2"), new ArrayList<>(countsMap.keySet()));
+
+    assertEquals(3, (long) countsMap.get("f1"));
+    assertEquals(6, (long) countsMap.get("f2"));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testSortedTermCounts_count_desc() throws Exception {
+    TermCounts termCounts = new TermCounts(reader);
+
+    Map<String, Long> countsMap = termCounts.sortedTermCounts(TermCountsOrder.COUNT_DESC);
+    assertEquals(Arrays.asList("f2", "f1"), new ArrayList<>(countsMap.keySet()));
+
+    assertEquals(3, (long) countsMap.get("f1"));
+    assertEquals(6, (long) countsMap.get("f2"));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testSortedTermCounts_name_asc() throws Exception {
+    TermCounts termCounts = new TermCounts(reader);
+
+    Map<String, Long> countsMap = termCounts.sortedTermCounts(TermCountsOrder.NAME_ASC);
+    assertEquals(Arrays.asList("f1", "f2"), new ArrayList<>(countsMap.keySet()));
+
+    assertEquals(3, (long) countsMap.get("f1"));
+    assertEquals(6, (long) countsMap.get("f2"));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testSortedTermCounts_name_desc() throws Exception {
+    TermCounts termCounts = new TermCounts(reader);
+
+    Map<String, Long> countsMap = termCounts.sortedTermCounts(TermCountsOrder.NAME_DESC);
+    assertEquals(Arrays.asList("f2", "f1"), new ArrayList<>(countsMap.keySet()));
+
+    assertEquals(3, (long) countsMap.get("f1"));
+    assertEquals(6, (long) countsMap.get("f2"));
+  }
+
+}
\ No newline at end of file
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TopTermsTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TopTermsTest.java
new file mode 100644
index 00000000000..a726ad87a33
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/overview/TopTermsTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.overview;
+
+import java.util.List;
+
+import org.junit.Test;
+
+public class TopTermsTest extends OverviewTestBase {
+
+  @Test
+  public void testGetTopTerms() throws Exception {
+    TopTerms topTerms = new TopTerms(reader);
+    List<TermStats> result = topTerms.getTopTerms("f2", 2);
+
+    assertEquals("a", result.get(0).getDecodedTermText());
+    assertEquals(3, result.get(0).getDocFreq());
+    assertEquals("f2", result.get(0).getField());
+
+    assertEquals("c", result.get(1).getDecodedTermText());
+    assertEquals(2, result.get(1).getDocFreq());
+    assertEquals("f2", result.get(1).getField());
+  }
+
+}
diff --git a/lucene/luke/src/test/org/apache/lucene/luke/models/search/SearchImplTest.java b/lucene/luke/src/test/org/apache/lucene/luke/models/search/SearchImplTest.java
new file mode 100644
index 00000000000..e9603cf4b3e
--- /dev/null
+++ b/lucene/luke/src/test/org/apache/lucene/luke/models/search/SearchImplTest.java
@@ -0,0 +1,380 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.luke.models.search;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.DoubleDocValuesField;
+import org.apache.lucene.document.DoublePoint;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FloatDocValuesField;
+import org.apache.lucene.document.FloatPoint;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.LongPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedDocValuesField;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.document.SortedSetDocValuesField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.luke.models.LukeException;
+import org.apache.lucene.queryparser.classic.QueryParser;
+import org.apache.lucene.search.PointRangeQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.SortedNumericSortField;
+import org.apache.lucene.search.SortedSetSortField;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.Test;
+
+public class SearchImplTest extends LuceneTestCase {
+
+  private IndexReader reader;
+  private Directory dir;
+  private Path indexDir;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    createIndex();
+    dir = newFSDirectory(indexDir);
+    reader = DirectoryReader.open(dir);
+  }
+
+  private void createIndex() throws IOException {
+    indexDir = createTempDir("testIndex");
+
+    Directory dir = newFSDirectory(indexDir);
+    RandomIndexWriter writer = new RandomIndexWriter(random(), dir, new StandardAnalyzer());
+
+    for (int i = 0; i < 10; i++) {
+      Document doc1 = new Document();
+      doc1.add(newTextField("f1", "Apple Pie", Field.Store.YES));
+      doc1.add(new SortedDocValuesField("f2", new BytesRef("a" + (i * 10 + 1))));
+      doc1.add(new SortedSetDocValuesField("f3", new BytesRef("a" + (i * 10 + 1))));
+      doc1.add(new NumericDocValuesField("f4", i * 10 + 1L));
+      doc1.add(new FloatDocValuesField("f5", i * 10 + 1.0f));
+      doc1.add(new DoubleDocValuesField("f6", i * 10 + 1.0));
+      doc1.add(new SortedNumericDocValuesField("f7", i * 10 + 1L));
+      doc1.add(new IntPoint("f8", i * 10 + 1));
+      doc1.add(new LongPoint("f9", i * 10 + 1L));
+      doc1.add(new FloatPoint("f10", i * 10 + 1.0f));
+      doc1.add(new DoublePoint("f11", i * 10 + 1.0));
+      writer.addDocument(doc1);
+
+      Document doc2 = new Document();
+      doc2.add(newTextField("f1", "Brownie", Field.Store.YES));
+      doc2.add(new SortedDocValuesField("f2", new BytesRef("b" + (i * 10 + 2))));
+      doc2.add(new SortedSetDocValuesField("f3", new BytesRef("b" + (i * 10 + 2))));
+      doc2.add(new NumericDocValuesField("f4", i * 10 + 2L));
+      doc2.add(new FloatDocValuesField("f5", i * 10 + 2.0f));
+      doc2.add(new DoubleDocValuesField("f6", i * 10 + 2.0));
+      doc2.add(new SortedNumericDocValuesField("f7", i * 10 + 2L));
+      doc2.add(new IntPoint("f8", i * 10 + 2));
+      doc2.add(new LongPoint("f9", i * 10 + 2L));
+      doc2.add(new FloatPoint("f10", i * 10 + 2.0f));
+      doc2.add(new DoublePoint("f11", i * 10 + 2.0));
+      writer.addDocument(doc2);
+
+      Document doc3 = new Document();
+      doc3.add(newTextField("f1", "Chocolate Pie", Field.Store.YES));
+      doc3.add(new SortedDocValuesField("f2", new BytesRef("c" + (i * 10 + 3))));
+      doc3.add(new SortedSetDocValuesField("f3", new BytesRef("c" + (i * 10 + 3))));
+      doc3.add(new NumericDocValuesField("f4", i * 10 + 3L));
+      doc3.add(new FloatDocValuesField("f5", i * 10 + 3.0f));
+      doc3.add(new DoubleDocValuesField("f6", i * 10 + 3.0));
+      doc3.add(new SortedNumericDocValuesField("f7", i * 10 + 3L));
+      doc3.add(new IntPoint("f8", i * 10 + 3));
+      doc3.add(new LongPoint("f9", i * 10 + 3L));
+      doc3.add(new FloatPoint("f10", i * 10 + 3.0f));
+      doc3.add(new DoublePoint("f11", i * 10 + 3.0));
+      writer.addDocument(doc3);
+
+      Document doc4 = new Document();
+      doc4.add(newTextField("f1", "Doughnut", Field.Store.YES));
+      doc4.add(new SortedDocValuesField("f2", new BytesRef("d" + (i * 10 + 4))));
+      doc4.add(new SortedSetDocValuesField("f3", new BytesRef("d" + (i * 10 + 4))));
+      doc4.add(new NumericDocValuesField("f4", i * 10 + 4L));
+      doc4.add(new FloatDocValuesField("f5", i * 10 + 4.0f));
+      doc4.add(new DoubleDocValuesField("f6", i * 10 + 4.0));
+      doc4.add(new SortedNumericDocValuesField("f7", i * 10 + 4L));
+      doc4.add(new IntPoint("f8", i * 10 + 4));
+      doc4.add(new LongPoint("f9", i * 10 + 4L));
+      doc4.add(new FloatPoint("f10", i * 10 + 4.0f));
+      doc4.add(new DoublePoint("f11", i * 10 + 4.0));
+      writer.addDocument(doc4);
+
+      Document doc5 = new Document();
+      doc5.add(newTextField("f1", "Eclair", Field.Store.YES));
+      doc5.add(new SortedDocValuesField("f2", new BytesRef("e" + (i * 10 + 5))));
+      doc5.add(new SortedSetDocValuesField("f3", new BytesRef("e" + (i * 10 + 5))));
+      doc5.add(new NumericDocValuesField("f4", i * 10 + 5L));
+      doc5.add(new FloatDocValuesField("f5", i * 10 + 5.0f));
+      doc5.add(new DoubleDocValuesField("f6", i * 10 + 5.0));
+      doc5.add(new SortedNumericDocValuesField("f7", i * 10 + 5L));
+      doc5.add(new IntPoint("f8", i * 10 + 5));
+      doc5.add(new LongPoint("f9", i * 10 + 5L));
+      doc5.add(new FloatPoint("f10", i * 10 + 5.0f));
+      doc5.add(new DoublePoint("f11", i * 10 + 5.0));
+      writer.addDocument(doc5);
+    }
+    writer.commit();
+    writer.close();
+    dir.close();
+  }
+
+  @Override
+  public void tearDown() throws Exception {
+    super.tearDown();
+    reader.close();
+    dir.close();
+  }
+
+  @Test
+  public void testGetSortableFieldNames() {
+    SearchImpl search = new SearchImpl(reader);
+    assertArrayEquals(new String[]{"f2", "f3", "f4", "f5", "f6", "f7"},
+        search.getSortableFieldNames().toArray());
+  }
+
+  @Test
+  public void testGetSearchableFieldNames() {
+    SearchImpl search = new SearchImpl(reader);
+    assertArrayEquals(new String[]{"f1"},
+        search.getSearchableFieldNames().toArray());
+  }
+
+  @Test
+  public void testGetRangeSearchableFieldNames() {
+    SearchImpl search = new SearchImpl(reader);
+    assertArrayEquals(new String[]{"f8", "f9", "f10", "f11"}, search.getRangeSearchableFieldNames().toArray());
+  }
+
+  @Test
+  public void testParseClassic() {
+    SearchImpl search = new SearchImpl(reader);
+    QueryParserConfig config = new QueryParserConfig.Builder()
+        .allowLeadingWildcard(true)
+        .defaultOperator(QueryParserConfig.Operator.AND)
+        .fuzzyMinSim(1.0f)
+        .build();
+    Query q = search.parseQuery("app~ f2:*ie", "f1", new StandardAnalyzer(),
+        config, false);
+    assertEquals("+f1:app~1 +f2:*ie", q.toString());
+  }
+
+  @Test
+  public void testParsePointRange() {
+    SearchImpl search = new SearchImpl(reader);
+    Map<String, Class<? extends Number>> types = new HashMap<>();
+    types.put("f8", Integer.class);
+
+    QueryParserConfig config = new QueryParserConfig.Builder()
+        .useClassicParser(false)
+        .typeMap(types)
+        .build();
+    Query q = search.parseQuery("f8:[10 TO 20]", "f1", new StandardAnalyzer(),
+        config, false);
+    assertEquals("f8:[10 TO 20]", q.toString());
+    assertTrue(q instanceof PointRangeQuery);
+  }
+
+  @Test
+  public void testGuessSortTypes() {
+    SearchImpl search = new SearchImpl(reader);
+
+    assertTrue(search.guessSortTypes("f1").isEmpty());
+
+    assertArrayEquals(
+        new SortField[]{
+            new SortField("f2", SortField.Type.STRING),
+            new SortField("f2", SortField.Type.STRING_VAL)},
+        search.guessSortTypes("f2").toArray());
+
+    assertArrayEquals(
+        new SortField[]{new SortedSetSortField("f3", false)},
+        search.guessSortTypes("f3").toArray());
+
+    assertArrayEquals(
+        new SortField[]{
+            new SortField("f4", SortField.Type.INT),
+            new SortField("f4", SortField.Type.LONG),
+            new SortField("f4", SortField.Type.FLOAT),
+            new SortField("f4", SortField.Type.DOUBLE)},
+        search.guessSortTypes("f4").toArray());
+
+    assertArrayEquals(
+        new SortField[]{
+            new SortField("f5", SortField.Type.INT),
+            new SortField("f5", SortField.Type.LONG),
+            new SortField("f5", SortField.Type.FLOAT),
+            new SortField("f5", SortField.Type.DOUBLE)},
+        search.guessSortTypes("f5").toArray());
+
+    assertArrayEquals(
+        new SortField[]{
+            new SortField("f6", SortField.Type.INT),
+            new SortField("f6", SortField.Type.LONG),
+            new SortField("f6", SortField.Type.FLOAT),
+            new SortField("f6", SortField.Type.DOUBLE)},
+        search.guessSortTypes("f6").toArray());
+
+    assertArrayEquals(
+        new SortField[]{
+            new SortedNumericSortField("f7", SortField.Type.INT),
+            new SortedNumericSortField("f7", SortField.Type.LONG),
+            new SortedNumericSortField("f7", SortField.Type.FLOAT),
+            new SortedNumericSortField("f7", SortField.Type.DOUBLE)},
+        search.guessSortTypes("f7").toArray());
+  }
+
+  @Test(expected = LukeException.class)
+  public void testGuessSortTypesNoSuchField() {
+    SearchImpl search = new SearchImpl(reader);
+    search.guessSortTypes("unknown");
+  }
+
+  @Test
+  public void testGetSortType() {
+    SearchImpl search = new SearchImpl(reader);
+
+    assertFalse(search.getSortType("f1", "STRING", false).isPresent());
+
+    assertEquals(new SortField("f2", SortField.Type.STRING, false),
+        search.getSortType("f2", "STRING", false).get());
+    assertFalse(search.getSortType("f2", "INT", false).isPresent());
+
+    assertEquals(new SortedSetSortField("f3", false),
+        search.getSortType("f3", "CUSTOM", false).get());
+
+    assertEquals(new SortField("f4", SortField.Type.LONG, false),
+        search.getSortType("f4", "LONG", false).get());
+    assertFalse(search.getSortType("f4", "STRING", false).isPresent());
+
+    assertEquals(new SortField("f5", SortField.Type.FLOAT, false),
+        search.getSortType("f5", "FLOAT", false).get());
+    assertFalse(search.getSortType("f5", "STRING", false).isPresent());
+
+    assertEquals(new SortField("f6", SortField.Type.DOUBLE, false),
+        search.getSortType("f6", "DOUBLE", false).get());
+    assertFalse(search.getSortType("f6", "STRING", false).isPresent());
+
+    assertEquals(new SortedNumericSortField("f7", SortField.Type.LONG, false),
+        search.getSortType("f7", "LONG", false).get());
+    assertFalse(search.getSortType("f7", "STRING", false).isPresent());
+  }
+
+  @Test(expected = LukeException.class)
+  public void testGetSortTypeNoSuchField() {
+    SearchImpl search = new SearchImpl(reader);
+
+    search.getSortType("unknown", "STRING", false);
+  }
+
+  @Test
+  public void testSearch() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("apple");
+    SearchResults res = search.search(query, new SimilarityConfig.Builder().build(), null, 10, true);
+
+    assertEquals(10, res.getTotalHits().value);
+    assertEquals(10, res.size());
+    assertEquals(0, res.getOffset());
+  }
+
+  @Test
+  public void testSearchWithSort() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("apple");
+    Sort sort = new Sort(new SortField("f2", SortField.Type.STRING, true));
+    SearchResults res = search.search(query, new SimilarityConfig.Builder().build(), sort, null, 10, true);
+
+    assertEquals(10, res.getTotalHits().value);
+    assertEquals(10, res.size());
+    assertEquals(0, res.getOffset());
+  }
+
+  @Test
+  public void testNextPage() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("pie");
+    search.search(query, new SimilarityConfig.Builder().build(), null, 10, true);
+    Optional<SearchResults> opt = search.nextPage();
+    assertTrue(opt.isPresent());
+
+    SearchResults res = opt.get();
+    assertEquals(20, res.getTotalHits().value);
+    assertEquals(10, res.size());
+    assertEquals(10, res.getOffset());
+  }
+
+  @Test(expected = LukeException.class)
+  public void testNextPageSearchNotStarted() {
+    SearchImpl search = new SearchImpl(reader);
+    search.nextPage();
+  }
+
+  @Test
+  public void testNextPageNoMoreResults() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("pie");
+    search.search(query, new SimilarityConfig.Builder().build(), null, 10, true);
+    search.nextPage();
+    assertFalse(search.nextPage().isPresent());
+  }
+
+  @Test
+  public void testPrevPage() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("pie");
+    search.search(query, new SimilarityConfig.Builder().build(), null, 10, true);
+    search.nextPage();
+    Optional<SearchResults> opt = search.prevPage();
+    assertTrue(opt.isPresent());
+
+    SearchResults res = opt.get();
+    assertEquals(20, res.getTotalHits().value);
+    assertEquals(10, res.size());
+    assertEquals(0, res.getOffset());
+  }
+
+  @Test(expected = LukeException.class)
+  public void testPrevPageSearchNotStarted() {
+    SearchImpl search = new SearchImpl(reader);
+    search.prevPage();
+  }
+
+  @Test
+  public void testPrevPageNoMoreResults() throws Exception {
+    SearchImpl search = new SearchImpl(reader);
+    Query query = new QueryParser("f1", new StandardAnalyzer()).parse("pie");
+    search.search(query, new SimilarityConfig.Builder().build(), null, 10, true);
+    assertFalse(search.prevPage().isPresent());
+  }
+
+}
diff --git a/lucene/module-build.xml b/lucene/module-build.xml
index d5798debf9f..0e6e6939773 100644
--- a/lucene/module-build.xml
+++ b/lucene/module-build.xml
@@ -714,4 +714,26 @@
     </ant>
     <property name="suggest-javadocs.uptodate" value="true"/>
   </target>
+
+  <property name="luke.jar" value="${common.dir}/build/luke/lucene-luke-${version}.jar"/>
+  <target name="check-luke-uptodate" unless="luke.uptodate">
+    <module-uptodate name="luke" jarfile="${luke.jar}" property="luke.uptodate"/>
+  </target>
+  <target name="jar-luke" unless="luke.uptodate" depends="check-luke-uptodate">
+    <ant dir="${common.dir}/luke" target="jar-core" inheritAll="false">
+      <propertyset refid="uptodate.and.compiled.properties"/>
+    </ant>
+    <property name="luke.uptodate" value="true"/>
+  </target>
+
+  <property name="luke-javadoc.jar" value="${common.dir}/build/luke/lucene-luke-${version}-javadoc.jar"/>
+  <target name="check-luke-javadocs-uptodate" unless="luke-javadocs.uptodate">
+    <module-uptodate name="luke" jarfile="${luke-javadoc.jar}" property="luke-javadocs.uptodate"/>
+  </target>
+  <target name="javadocs-luke" unless="luke-javadocs.uptodate" depends="check-luke-javadocs-uptodate">
+    <ant dir="${common.dir}/luke" target="javadocs" inheritAll="false">
+      <propertyset refid="uptodate.and.compiled.properties"/>
+    </ant>
+    <property name="luke-javadocs.uptodate" value="true"/>
+  </target>
 </project>
diff --git a/lucene/tools/junit4/tests.policy b/lucene/tools/junit4/tests.policy
index c698c2cd47a..74949813b7c 100644
--- a/lucene/tools/junit4/tests.policy
+++ b/lucene/tools/junit4/tests.policy
@@ -66,7 +66,11 @@ grant {
   permission java.lang.RuntimePermission "accessClassInPackage.org.apache.xerces.util";
   // needed by jacoco to dump coverage
   permission java.lang.RuntimePermission "shutdownHooks";
-  
+  // needed by org.apache.logging.log4j
+  permission java.lang.RuntimePermission "getenv.*";
+  permission java.lang.RuntimePermission "getClassLoader";
+  permission java.lang.RuntimePermission "setContextClassLoader";
+
   // read access to all system properties:
   permission java.util.PropertyPermission "*", "read";
   // write access to only these:
