initial import
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..274cbc0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# Jira archive for Apache Lucene
+
+This repository serves for:
+
+https://issues.apache.org/jira/browse/LUCENE-10557
+
+- Archive Jira attachments
+- Drafting Label management
+- Drafting Issue templates
+- [Migration script](./migration/)
diff --git a/attachments/LUCENE-2562/LUCENE-2562-Ivy.patch b/attachments/LUCENE-2562/LUCENE-2562-Ivy.patch
new file mode 100644
index 0000000..72c640f
--- /dev/null
+++ b/attachments/LUCENE-2562/LUCENE-2562-Ivy.patch
@@ -0,0 +1,3361 @@
+Index: src/org/apache/lucene/luke/core/HighFreqTerms.java
+===================================================================
+--- src/org/apache/lucene/luke/core/HighFreqTerms.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/HighFreqTerms.java	(working copy)
+@@ -100,10 +100,10 @@
+   }
+   
+   /**
+-   * 
+-   * @param reader
+-   * @param numTerms
+-   * @param field
++   * // TODO move this method to org.apache.lucene.misc.HighFreqTerms
++   * @param reader IndexReader
++   * @param numTerms the max number of terms
++   * @param fieldNames tye array of field names
+    * @return TermStats[] ordered by terms with highest docFreq first.
+    * @throws Exception
+    */
+Index: src/org/apache/lucene/luke/core/IndexInfo.java
+===================================================================
+--- src/org/apache/lucene/luke/core/IndexInfo.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/IndexInfo.java	(working copy)
+@@ -177,6 +177,7 @@
+       }
+     }
+   }
++
+   
+   /**
+    * @return the reader
+Index: src/org/apache/lucene/luke/core/TableComparator.java
+===================================================================
+--- src/org/apache/lucene/luke/core/TableComparator.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/TableComparator.java	(working copy)
+@@ -1,63 +0,0 @@
+-package org.apache.lucene.luke.core;
+-
+-/*
+- * 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.
+- */
+-
+-import java.util.Comparator;
+-
+-import org.apache.pivot.collections.Dictionary;
+-import org.apache.pivot.collections.Map;
+-import org.apache.pivot.wtk.SortDirection;
+-import org.apache.pivot.wtk.TableView;
+-
+-public class TableComparator implements Comparator<Map<String,String>> {
+-  private TableView tableView;
+-  
+-  public TableComparator(TableView fieldsTable) {
+-    if (fieldsTable == null) {
+-      throw new IllegalArgumentException();
+-    }
+-    
+-    this.tableView = fieldsTable;
+-  }
+-  
+-  @Override
+-  public int compare(Map<String,String> o1, Map<String,String> o2) {
+-    Dictionary.Pair<String, SortDirection> sort = tableView.getSort().get(0);
+-
+-    int result;
+-    if (sort.key.equals("name")) {
+-      // sort by name
+-      result = o1.get(sort.key).compareTo(o2.get(sort.key));
+-    } else if (sort.key.equals("termCount")) {
+-      // sort by termCount
+-      Integer c1 = Integer.parseInt(o1.get(sort.key));
+-      Integer c2 = Integer.parseInt(o2.get(sort.key));
+-      result = c1.compareTo(c2);
+-    } else {
+-      // other (ignored)
+-      result = 0;
+-    }
+-    //int result = o1.get("name").compareTo(o2.get("name"));
+-    //SortDirection sortDirection = tableView.getSort().get("name");
+-    SortDirection sortDirection = sort.value;
+-    result *= (sortDirection == SortDirection.DESCENDING ? 1 : -1);
+-
+-    return result * -1;
+-  }
+-  
+-}
+\ No newline at end of file
+Index: src/org/apache/lucene/luke/core/TermStats.java
+===================================================================
+--- src/org/apache/lucene/luke/core/TermStats.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/TermStats.java	(working copy)
+@@ -26,13 +26,15 @@
+   public long totalTermFreq;
+   
+   TermStats(String field, BytesRef termtext, int df) {
+-    this.termtext = (BytesRef)termtext.clone();
++    //this.termtext = (BytesRef)termtext.clone();
++    this.termtext = BytesRef.deepCopyOf(termtext);
+     this.field = field;
+     this.docFreq = df;
+   }
+   
+   TermStats(String field, BytesRef termtext, int df, long tf) {
+-    this.termtext = (BytesRef)termtext.clone();
++    //this.termtext = (BytesRef)termtext.clone();
++    this.termtext = BytesRef.deepCopyOf(termtext);
+     this.field = field;
+     this.docFreq = df;
+     this.totalTermFreq = tf;
+Index: src/org/apache/lucene/luke/core/Util.java
+===================================================================
+--- src/org/apache/lucene/luke/core/Util.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/Util.java	(working copy)
+@@ -19,11 +19,19 @@
+ 
+ import java.io.ByteArrayOutputStream;
+ import java.io.File;
++import java.lang.reflect.Constructor;
++import java.lang.reflect.Method;
++import java.util.ArrayList;
++import java.util.Arrays;
+ import java.util.HashMap;
++import java.util.List;
+ 
+ import org.apache.lucene.document.DateTools.Resolution;
++import org.apache.lucene.index.FieldInfo;
+ import org.apache.lucene.index.FieldInfo.IndexOptions;
+ import org.apache.lucene.index.IndexableField;
++import org.apache.lucene.luke.core.decoders.*;
++import org.apache.lucene.search.similarities.TFIDFSimilarity;
+ import org.apache.lucene.store.Directory;
+ import org.apache.lucene.store.FSDirectory;
+ import org.apache.lucene.store.MMapDirectory;
+@@ -161,18 +169,20 @@
+     return sb.toString();
+   }
+   
+-  public static String fieldFlags(IndexableField f) {
+-    if (f == null) {
+-      return "-----------";
+-    }
++  public static String fieldFlags(IndexableField f, FieldInfo info) {
++    //if (f == null) {
++    //  return "-----------";
++    //}
+     StringBuffer flags = new StringBuffer();
+-    if (f != null && f.fieldType().indexed()) flags.append("I");
++    //if (f != null && f.fieldType().indexed()) flags.append("I");
++    if (info != null && info.isIndexed()) flags.append("I");
+     else flags.append("-");
+     if (f != null && f.fieldType().tokenized()) flags.append("T");
+     else flags.append("-");
+     if (f != null && f.fieldType().stored()) flags.append("S");
+     else flags.append("-");
+-    if (f != null && f.fieldType().storeTermVectors()) flags.append("V");
++    //if (f != null && f.fieldType().storeTermVectors()) flags.append("V");
++    if (info != null && info.hasVectors()) flags.append("V");
+     else flags.append("-");
+     if (f != null && f.fieldType().storeTermVectorOffsets()) flags.append("o");
+     else flags.append("-");
+@@ -180,9 +190,13 @@
+     else flags.append("-");
+     if (f != null && f.fieldType().storeTermVectorPayloads()) flags.append("a");
+     else flags.append("-");
+-    IndexOptions opts = f.fieldType().indexOptions();
++    if (info != null && info.hasPayloads()) flags.append("P");
++    else flags.append("-");
++    //IndexOptions opts = f.fieldType().indexOptions();
++    IndexOptions opts = info.getIndexOptions();
+     // TODO: how to handle these codes
+-    if (f.fieldType().indexed() && opts != null) {
++    //if (f.fieldType().indexed() && opts != null) {
++    if (info.isIndexed() && opts != null) {
+       switch (opts) {
+       case DOCS_ONLY:
+         flags.append("1");
+@@ -199,13 +213,32 @@
+     } else {
+       flags.append("-");
+     }
+-    if (f != null && f.fieldType().omitNorms()) flags.append("O");
++    //if (f != null && f.fieldType().omitNorms()) flags.append("O");
++    if (info != null && !info.hasNorms()) flags.append("O");
+     else flags.append("-");
++    // TODO lazy
++    flags.append("-");
++    if (f != null && f.binaryValue() != null) flags.append("B");
++    else flags.append("-");
+ 
+ 
+     return flags.toString();
+   }
+-  
++
++  public static String docValuesType(FieldInfo info) {
++    if (info == null || !info.hasDocValues() || info.getDocValuesType() == null) {
++      return "---";
++    }
++    return info.getDocValuesType().name();
++  }
++
++  public static String normType(FieldInfo info) {
++    if (info == null || !info.hasNorms() || info.getNormType() == null) {
++      return "---";
++    }
++    return info.getNormType().name();
++  }
++
+   public static Resolution getResolution(String key) {
+     if (key == null || key.trim().length() == 0) {
+       return Resolution.MILLISECOND;
+@@ -270,4 +303,56 @@
+       return String.valueOf(len / 1048576);
+     }
+   }
++
++  public static float decodeNormValue(long v, String fieldName, TFIDFSimilarity sim) throws Exception {
++    try {
++      return sim.decodeNormValue(v);
++    } catch (Exception e) {
++      throw new Exception("ERROR decoding norm for field "  + fieldName + ":" + e.toString());
++    }
++  }
++
++  public static long encodeNormValue(float v, String fieldName, TFIDFSimilarity sim) throws Exception {
++    try {
++      return sim.encodeNormValue(v);
++    } catch (Exception e) {
++      throw new Exception("ERROR encoding norm for field "  + fieldName + ":" + e.toString());
++    }
++  }
++
++
++  public static List<Decoder> loadDecoders() {
++    List decoders = new ArrayList();
++    // default decoders
++    decoders.add(new BinaryDecoder());
++    decoders.add(new DateDecoder());
++    decoders.add(new NumDoubleDecoder());
++    decoders.add(new NumFloatDecoder());
++    decoders.add(new NumIntDecoder());
++    decoders.add(new NumLongDecoder());
++    decoders.add(new StringDecoder());
++
++    // load external decoders
++    try {
++      String extLoaders = System.getProperty("luke.ext.decoder.loader");
++      if (extLoaders != null) {
++        String[] classes = extLoaders.split(",");
++        for (String className : classes) {
++          Class clazz = Class.forName(className);
++          Class[] interfaces = clazz.getInterfaces();
++          if (Arrays.asList(interfaces).indexOf(DecoderLoader.class) < 0) {
++            throw new Exception(className + " is not a DecoderLoader.");
++          }
++          DecoderLoader loader = (DecoderLoader)clazz.newInstance();
++          List<Decoder> extDecoders = loader.loadDecoders();
++          for (Decoder dec : extDecoders) {
++            decoders.add(dec);
++          }
++        }
++      }
++    } catch (Exception e) {
++      e.printStackTrace();
++    }
++    return decoders;
++  }
+ }
+Index: src/org/apache/lucene/luke/core/decoders/BinaryDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/BinaryDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/BinaryDecoder.java	(working copy)
+@@ -19,23 +19,18 @@
+ 
+ import org.apache.lucene.document.Field;
+ import org.apache.lucene.luke.core.Util;
++import org.apache.lucene.util.BytesRef;
+ 
+ public class BinaryDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) throws Exception {
+-    byte[] data;
+-    if (value instanceof byte[]) {
+-      data = (byte[])value;
+-    } else {
+-      data = value.toString().getBytes();
+-    }
+-    return Util.bytesToHex(data, 0, data.length, false);
++  public String decodeTerm(String fieldName, BytesRef value) throws Exception {
++    return Util.bytesToHex(value.bytes, 0, value.length, false);
+   }
+ 
+   @Override
+   public String decodeStored(String fieldName, Field value) throws Exception {
+-    return decodeTerm(fieldName, value);
++    return decodeTerm(fieldName, new BytesRef(value.stringValue()));
+   }
+   
+   public String toString() {
+Index: src/org/apache/lucene/luke/core/decoders/DateDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/DateDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/DateDecoder.java	(working copy)
+@@ -19,17 +19,18 @@
+ 
+ import org.apache.lucene.document.DateTools;
+ import org.apache.lucene.document.Field;
++import org.apache.lucene.util.BytesRef;
+ 
+ public class DateDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) throws Exception {
++  public String decodeTerm(String fieldName, BytesRef value) throws Exception {
+     return DateTools.stringToDate(value.toString()).toString();
+   }
+   
+   @Override
+   public String decodeStored(String fieldName, Field value) throws Exception {
+-    return decodeTerm(fieldName, value.stringValue());
++    return decodeTerm(fieldName, new BytesRef(value.stringValue()));
+   }
+   
+   public String toString() {
+Index: src/org/apache/lucene/luke/core/decoders/Decoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/Decoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/Decoder.java	(working copy)
+@@ -18,9 +18,10 @@
+  */
+ 
+ import org.apache.lucene.document.Field;
++import org.apache.lucene.util.BytesRef;
+ 
+ public interface Decoder {
+   
+-  public String decodeTerm(String fieldName, Object value) throws Exception;
++  public String decodeTerm(String fieldName, BytesRef value) throws Exception;
+   public String decodeStored(String fieldName, Field value) throws Exception;
+ }
+Index: src/org/apache/lucene/luke/core/decoders/DecoderLoader.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/DecoderLoader.java	(revision 0)
++++ src/org/apache/lucene/luke/core/decoders/DecoderLoader.java	(working copy)
+@@ -0,0 +1,24 @@
++package org.apache.lucene.luke.core.decoders;
++
++/*
++ * 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.
++ */
++
++import java.util.List;
++
++public interface DecoderLoader {
++  public List<Decoder> loadDecoders();
++}
+Index: src/org/apache/lucene/luke/core/decoders/NumDoubleDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/NumDoubleDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/NumDoubleDecoder.java	(working copy)
+@@ -1,5 +1,22 @@
+ package org.apache.lucene.luke.core.decoders;
+ 
++/*
++ * 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.
++ */
++
+ import org.apache.lucene.document.Field;
+ import org.apache.lucene.util.BytesRef;
+ import org.apache.lucene.util.NumericUtils;
+@@ -7,9 +24,8 @@
+ public class NumDoubleDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) {
+-    BytesRef ref = new BytesRef(value.toString());
+-    return Double.toString(NumericUtils.sortableLongToDouble(NumericUtils.prefixCodedToLong(ref)));
++  public String decodeTerm(String fieldName, BytesRef value) {
++    return Double.toString(NumericUtils.sortableLongToDouble(NumericUtils.prefixCodedToLong(value)));
+   }
+ 
+   @Override
+@@ -18,7 +34,7 @@
+   }
+ 
+   public String toString() {
+-    return "numeric-double";
++    return "numeric double";
+   }
+ 
+ }
+Index: src/org/apache/lucene/luke/core/decoders/NumFloatDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/NumFloatDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/NumFloatDecoder.java	(working copy)
+@@ -6,9 +6,9 @@
+ 
+ public class NumFloatDecoder implements Decoder {
+   @Override
+-  public String decodeTerm(String fieldName, Object value) {
+-    BytesRef ref = new BytesRef(value.toString());
+-    return Float.toString(NumericUtils.sortableIntToFloat(NumericUtils.prefixCodedToInt(ref)));
++  public String decodeTerm(String fieldName, BytesRef value) {
++    //BytesRef ref = new BytesRef(value.toString());
++    return Float.toString(NumericUtils.sortableIntToFloat(NumericUtils.prefixCodedToInt(value)));
+   }
+ 
+   @Override
+@@ -17,7 +17,7 @@
+   }
+ 
+   public String toString() {
+-    return "numeric-float";
++    return "numeric float";
+   }
+ 
+ }
+Index: src/org/apache/lucene/luke/core/decoders/NumIntDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/NumIntDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/NumIntDecoder.java	(working copy)
+@@ -24,9 +24,8 @@
+ public class NumIntDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) {
+-    BytesRef ref = new BytesRef(value.toString());
+-    return Integer.toString(NumericUtils.prefixCodedToInt(ref));
++  public String decodeTerm(String fieldName, BytesRef value) {
++    return Integer.toString(NumericUtils.prefixCodedToInt(value));
+   }
+   
+   @Override
+@@ -35,7 +34,7 @@
+   }
+   
+   public String toString() {
+-    return "numeric-int";
++    return "numeric int";
+   }
+ 
+ }
+Index: src/org/apache/lucene/luke/core/decoders/NumLongDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/NumLongDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/NumLongDecoder.java	(working copy)
+@@ -24,9 +24,8 @@
+ public class NumLongDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) {
+-    BytesRef ref = new BytesRef(value.toString());
+-    return Long.toString(NumericUtils.prefixCodedToLong(ref));
++  public String decodeTerm(String fieldName, BytesRef value) {
++    return Long.toString(NumericUtils.prefixCodedToLong(value));
+   }
+   
+   @Override
+@@ -35,7 +34,7 @@
+   }
+   
+   public String toString() {
+-    return "numeric-long";
++    return "numeric long";
+   }
+ 
+ }
+Index: src/org/apache/lucene/luke/core/decoders/SolrDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/SolrDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/SolrDecoder.java	(working copy)
+@@ -24,8 +24,14 @@
+ 
+ import org.apache.lucene.document.Field;
+ import org.apache.lucene.luke.core.ClassFinder;
++import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.CharsRef;
+ import org.apache.solr.schema.FieldType;
+ 
++/**
++ * NOT Used.
++ * The logic here has moved to org.apache.lucene.ext.SolrDecoderLoader.
++ */
+ public class SolrDecoder implements Decoder {
+   private static final String solr_prefix = "org.apache.solr.schema.";
+   
+@@ -86,8 +92,9 @@
+     name = type;
+   }
+   
+-  public String decodeTerm(String fieldName, Object value) throws Exception {
+-    return fieldType.indexedToReadable(value.toString());
++  public String decodeTerm(String fieldName, BytesRef value) throws Exception {
++    CharsRef chars = fieldType.indexedToReadable(value, new CharsRef());
++    return chars.toString();
+   }
+   
+   public String decodeStored(String fieldName, Field value)
+@@ -100,3 +107,4 @@
+   }
+   
+ }
++
+Index: src/org/apache/lucene/luke/core/decoders/StringDecoder.java
+===================================================================
+--- src/org/apache/lucene/luke/core/decoders/StringDecoder.java	(revision 1655665)
++++ src/org/apache/lucene/luke/core/decoders/StringDecoder.java	(working copy)
+@@ -18,17 +18,18 @@
+  */
+ 
+ import org.apache.lucene.document.Field;
++import org.apache.lucene.util.BytesRef;
+ 
+ public class StringDecoder implements Decoder {
+ 
+   @Override
+-  public String decodeTerm(String fieldName, Object value) {
+-    return value != null ? value.toString() : "(null)";
++  public String decodeTerm(String fieldName, BytesRef value) {
++    return value != null ? value.utf8ToString() : "(null)";
+   }
+   
+   @Override
+   public String decodeStored(String fieldName, Field value) {
+-    return decodeTerm(fieldName, value.stringValue());
++    return value.stringValue();
+   }
+ 
+   public String toString() {
+Index: src/org/apache/lucene/luke/ext/SolrDecoderLoader.java
+===================================================================
+--- src/org/apache/lucene/luke/ext/SolrDecoderLoader.java	(revision 0)
++++ src/org/apache/lucene/luke/ext/SolrDecoderLoader.java	(working copy)
+@@ -0,0 +1,81 @@
++package org.apache.lucene.luke.ext;
++
++/*
++ * 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.
++ */
++
++import org.apache.lucene.document.Field;
++import org.apache.lucene.luke.core.ClassFinder;
++import org.apache.lucene.luke.core.decoders.Decoder;
++import org.apache.lucene.luke.core.decoders.DecoderLoader;
++import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.CharsRef;
++import org.apache.solr.schema.FieldType;
++
++import java.util.ArrayList;
++import java.util.List;
++
++public class SolrDecoderLoader implements DecoderLoader {
++  private static final String solr_prefix = "org.apache.solr.schema.";
++
++  @Override
++  public List<Decoder> loadDecoders() {
++    List<Decoder> decoders = new ArrayList<Decoder>();
++    try {
++      Class[] classes = ClassFinder.getInstantiableSubclasses(FieldType.class);
++      if (classes == null || classes.length == 0) {
++        throw new ClassNotFoundException("Missing Solr types???");
++      }
++      for (Class cls : classes) {
++        FieldType ft = (FieldType) cls.newInstance();
++        if (cls.getName().startsWith(solr_prefix)) {
++          String name = "solr." + cls.getName().substring(solr_prefix.length());
++          decoders.add(new SolrDecoder(name, ft));
++        }
++      }
++    } catch (Exception e) {
++      // TODO Auto-generated catch block
++      e.printStackTrace();
++    }
++    return decoders;
++  }
++}
++
++class SolrDecoder implements Decoder {
++  private String name;
++  private FieldType fieldType;
++
++  public SolrDecoder(String name, FieldType fieldType) {
++    this.name = name;
++    this.fieldType = fieldType;
++  }
++
++  public String decodeTerm(String fieldName, BytesRef value) throws Exception {
++    CharsRef chars = fieldType.indexedToReadable(value, new CharsRef());
++    return chars.toString();
++  }
++
++  public String decodeStored(String fieldName, Field value)
++    throws Exception {
++    return fieldType.storedToReadable(value);
++  }
++
++  public String toString() {
++    return name;
++  }
++
++}
++
+Index: src/org/apache/lucene/luke/ui/DocumentsTab.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/DocumentsTab.bxml	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/DocumentsTab.bxml	(working copy)
+@@ -1,169 +1,259 @@
+ <?xml version="1.0" encoding="UTF-8"?>
+ 
+ <luke:DocumentsTab bxml:id="DocumentsTab"
+-	styles="{verticalSpacing:2,horizontalSpacing:2,padding:4,backgroundColor:11}"
+ 	xmlns:bxml="http://pivot.apache.org/bxml" xmlns:content="org.apache.pivot.wtk.content"
+-	xmlns="org.apache.pivot.wtk" xmlns:luke="org.apache.lucene.luke.ui">
++	xmlns="org.apache.pivot.wtk" xmlns:luke="org.apache.lucene.luke.ui"
++	orientation="vertical" splitRatio="0.30">
+ 
++	<bxml:define>
++		<bxml:include bxml:id="posAndOffsetsWindow" src="PosAndOffsetsWindow.bxml" />
++	</bxml:define>
++	<bxml:define>
++		<bxml:include bxml:id="tvWindow" src="TermVectorWindow.bxml" />
++	</bxml:define>
++	<bxml:define>
++		<bxml:include bxml:id="fieldDataWindow" src="FieldDataWindow.bxml" />
++	</bxml:define>
++	<bxml:define>
++		<bxml:include bxml:id="fieldNormWindow" src="FieldNormWindow.bxml" />
++	</bxml:define>
+ 
+-	<columns>
+-		<TablePane.Column width="1*" />
+-	</columns>
+-	<rows>
+-		<TablePane.Row>
+-			<FlowPane styles="{padding:10}">
+-				<BoxPane orientation="vertical">
+-					<Label text="%documentsTab_browseByDocNum" />
+-					<BoxPane>
+-						<Label text="Doc. #:" />
+-						<Label text="0" />
+-						<PushButton preferredHeight="20" action="prevDoc">
+-							<buttonData>
+-								<content:ButtonData icon="/img/prev.png" />
+-							</buttonData>
+-						</PushButton>
+-						<TextInput preferredWidth="48" bxml:id="docNum" />
+-						<PushButton preferredHeight="20" action="nextDoc">
+-							<buttonData>
+-								<content:ButtonData icon="/img/next.png" />
+-							</buttonData>
+-						</PushButton>
+-						<Label bxml:id="maxDocs" text="?" />
++	<top>
++		<SplitPane orientation="horizontal" splitRatio="0.20" styles="{useShadow:true}">
++			<left>
++				<Border styles="{padding:1}">
++				<content>
++				<TablePane styles="{verticalSpacing:1,horizontalSpacing:1,padding:5,backgroundColor:11}">
++					<columns>
++						<TablePane.Column width="1*" />
++					</columns>
++					<rows>
++						<TablePane.Row>
++							<BoxPane orientation="vertical">
++								<Label text="%documentsTab_browseByDocNum" styles="{padding:2,font:{bold:true}}"/>
++								<Label text="Doc. #" styles="{padding:2}"/>
++								<BoxPane orientation="horizontal">
++									<Label text="0" styles="{padding:2}"/>
++									<PushButton preferredHeight="20" action="prevDoc">
++										<buttonData>
++											<content:ButtonData icon="/img/prev.png" />
++										</buttonData>
++									</PushButton>
++									<TextInput preferredWidth="48" bxml:id="docNum" />
++									<PushButton preferredHeight="20" action="nextDoc">
++										<buttonData>
++											<content:ButtonData icon="/img/next.png" />
++										</buttonData>
++									</PushButton>
++									<Label bxml:id="maxDocs" text="?" styles="{padding:2}"/>
++								</BoxPane>
++							</BoxPane>
++						</TablePane.Row>
++					</rows>
++				</TablePane>
++				</content>
++				</Border>
++			</left>
++			<right>
++				<SplitPane orientation="horizontal" splitRatio="0.50" styles="{useShadow:true}">
++					<left>
++						<Border styles="{padding:1}">
++						<content>
++						<TablePane styles="{verticalSpacing:2,horizontalSpacing:1,padding:5,backgroundColor:11}">
++							<columns>
++								<TablePane.Column width="1*" />
++							</columns>
++							<rows>
++								<TablePane.Row>
++									<BoxPane orientation="vertical">
++										<Label text="%documentsTab_browseByTerm" styles="{padding:1,font:{bold:true}}"/>
++										<Label text="%documentsTab_selectField" styles="{wrapText:true}"/>
++										<Label text="%documentsTab_enterTermHint" styles="{wrapText:true}"/>
++										<ListButton bxml:id="fieldsList" listSize="20" />
++										<Label text="%documentsTab_term" />
++										<BoxPane>
++											<PushButton buttonData="%documentsTab_firstTerm"
++																	action="showFirstTerm" />
++											<TextInput bxml:id="termText" />
++											<PushButton action="showNextTerm">
++												<buttonData>
++													<content:ButtonData icon="/img/next.png" />
++												</buttonData>
++											</PushButton>
++										</BoxPane>
++									</BoxPane>
++								</TablePane.Row>
+ 
+-					</BoxPane>
+-				</BoxPane>
++								<TablePane.Row>
++									<BoxPane>
++										<Label text="%documentsTab_decodedValue" />
++										<TextArea bxml:id="decText" />
++									</BoxPane>
++								</TablePane.Row>
++							</rows>
++						</TablePane>
++						</content>
++						</Border>
++					</left>
++					<right>
++						<Border styles="{padding:1}">
++						<content>
++						<TablePane styles="{verticalSpacing:2,horizontalSpacing:1,padding:5,backgroundColor:11}">
++							<columns>
++								<TablePane.Column width="1*" />
++							</columns>
++							<rows>
++								<TablePane.Row>
++									<BoxPane orientation="vertical">
++										<Label text="%documentsTab_browseDocsWithTerm" styles="{padding:1,font:{bold:true}}"/>
++										<Label text="%documentsTab_selectTerm" styles="{wrapText:true}"/>
++										<BoxPane>
++											<!--Label text="%documentsTab_document" /-->
++											<PushButton buttonData="%documentsTab_firstDoc" action="showFirstTermDoc" />
++											<PushButton action="showNextTermDoc">
++												<buttonData>
++													<content:ButtonData icon="/img/next.png" />
++												</buttonData>
++											</PushButton>
+ 
+-				<BoxPane orientation="vertical">
+-					<Label text="%documentsTab_browseByTerm" />
+-					<Label text="%documentsTab_enterTermHint" />
+-					<BoxPane>
+-						<PushButton buttonData="%documentsTab_firstTerm"
+-							action="showFirstTerm" />
+-						<Label text="%documentsTab_term" />
+-						<ListButton bxml:id="fieldsList" listSize="20" />
+-						<TextInput bxml:id="termText" />
+-						<PushButton action="showNextTerm">
+-							<buttonData>
+-								<content:ButtonData icon="/img/next.png" />
+-							</buttonData>
+-						</PushButton>
+-					</BoxPane>
+-				</BoxPane>
++											<Label text=" ("/>
++											<Label bxml:id="tdNum" text="?" />
++											<Label text=" of " />
++											<Label bxml:id="tdMax" text="?" />
++											<Label text=" documents )" />
++										</BoxPane>
++									</BoxPane>
++								</TablePane.Row>
+ 
++								<TablePane.Row>
++									<BoxPane orientation="vertical">
++										<BoxPane>
++											<Label text="%documentsTab_termFreqInDoc" />
++											<Label bxml:id="tFreq" text="?" />
++										</BoxPane>
++										<PushButton bxml:id="bPos" buttonData="%documentsTab_showPositions"
++																action="showPositions" />
++									</BoxPane>
++								</TablePane.Row>
+ 
+-				<!-- second row -->
+-				<Label text="%documentsTab_decodedValue" />
+-				<TextArea bxml:id="decText" />
++								<TablePane.Row>
++									<Separator/>
++								</TablePane.Row>
+ 
+-				<Separator />
+-				<BoxPane orientation="vertical">
+-					<BoxPane>
+-						<Label text="%documentsTab_browseDocsWithTerm" />
+-						<Label text="( " />
+-						<Label bxml:id="dFreq" text="0" />
+-						<Label text=" documents)" />
+-					</BoxPane>
++								<TablePane.Row>
++									<BoxPane>
++										<PushButton buttonData="%documentsTab_showAllDocs"
++																action="showAllTermDoc"/>
++										<BoxPane>
++											<PushButton action="deleteTermDoc">
++												<buttonData>
++													<content:ButtonData icon="/img/delete.gif" />
++												</buttonData>
++											</PushButton>
++											<Label text="%documentsTab_deleteAllDocs" styles="{padding:1}"/>
++										</BoxPane>
++									</BoxPane>
++								</TablePane.Row>
+ 
+-					<BoxPane>
+-						<Label text="%documentsTab_document" />
+-						<Label bxml:id="tdNum" text="?" />
+-						<Label text=" of " />
+-						<Label bxml:id="tdMax" text="?" />
+-						<PushButton buttonData="%documentsTab_firstDoc" action="showFirstTermDoc" />
+-						<PushButton action="showNextTermDoc">
+-							<buttonData>
+-								<content:ButtonData icon="/img/next.png" />
+-							</buttonData>
+-						</PushButton>
+-					</BoxPane>
++							</rows>
++						</TablePane>
++						</content>
++						</Border>
++					</right>
++				</SplitPane>
++			</right>
++		</SplitPane>
++	</top>
+ 
+-				</BoxPane>
+-				<BoxPane orientation="vertical">
+-					<PushButton buttonData="%documentsTab_showAllDocs"
+-						action="showAllTermDoc" />
+-					<PushButton buttonData="%documentsTab_deleteAllDocs"
+-						action="deleteTermDoc">
+-						<buttonData>
+-							<content:ButtonData icon="/img/delete.gif" />
+-						</buttonData>
+-					</PushButton>
+-				</BoxPane>
+-				<Label text=" " />
+-				<Label text="%documentsTab_termFreqInDoc" />
+-
+-				<Label bxml:id="tFreq" text="?" />
+-				<PushButton bxml:id="bPos" buttonData="%documentsTab_showPositions"
+-					action="showPositions" />
+-			</FlowPane>
+-		</TablePane.Row>
+-		<TablePane.Row>
+-			<TablePane styles="{verticalSpacing:1, horizontalSpacing:1}">
+-				<columns>
+-					<TablePane.Column width="1*" />
+-					<TablePane.Column />
+-				</columns>
+-				<rows>
+-					<TablePane.Row>
+-						<FlowPane>
+-							<Label text="Doc #:" />
+-							<Label bxml:id="docNum2" text="?" />
+-							<Label text=" " />
+-						</FlowPane>
+-						<FlowPane>
+-							<TablePane styles="{verticalSpacing:1, horizontalSpacing:1}">
+-								<columns>
+-									<TablePane.Column />
+-									<TablePane.Column />
+-									<TablePane.Column />
+-									<TablePane.Column />
+-									<TablePane.Column />
+-									<TablePane.Column />
+-								</columns>
+-								<rows>
+-									<TablePane.Row>
+-										<Label text="Flags: " />
+-										<Label text=" I - Indexed " />
+-										<Label text="  T - Tokenized " />
+-										<Label text="  S - Stored " />
+-										<Label text="  V - Term Vector " />
+-										<Label text=" (o - offsets; p - positions) " />
+-									</TablePane.Row>
+-									<TablePane.Row>
+-										<TablePane.Filler />
+-
+-										<Label text=" O - Omit Norms " />
+-										<Label text="  f - Omit TF " />
+-										<Label text="  L - Lazy " />
+-										<Label text="  B - Binary " />
+-									</TablePane.Row>
+-								</rows>
+-							</TablePane>
+-						</FlowPane>
+-					</TablePane.Row>
+-				</rows>
+-			</TablePane>
+-		</TablePane.Row>
+-		<TablePane.Row height="1*">
+-			<ScrollPane horizontalScrollBarPolicy="fill_to_capacity" styles="{backgroundColor:11}">
+-				<view>
+-					<TableView bxml:id="docTable">
++	<bottom>
++		<TablePane styles="{verticalSpacing:5,horizontalSpacing:1,padding:5,backgroundColor:11}">
++			<columns>
++				<TablePane.Column width="1*" />
++			</columns>
++			<rows>
++				<TablePane.Row>
++					<TablePane>
+ 						<columns>
+-							<TableView.Column name="field"
+-								headerData="%documentsTab_docTable_col1" />
+-							<TableView.Column name="itsvopfolb"
+-								headerData="%documentsTab_docTable_col2" />
+-							<TableView.Column name="norm"
+-								headerData="%documentsTab_docTable_col3" />
+-							<TableView.Column name="value"
+-								headerData="%documentsTab_docTable_col4" width="1*" />
++							<TablePane.Column width="1*" />
++							<TablePane.Column />
+ 						</columns>
++						<rows>
++							<TablePane.Row>
++								<FlowPane>
++									<Label text="Doc #:" />
++									<Label bxml:id="docNum2" text="?" />
++									<Label text=" " />
++								</FlowPane>
++								<FlowPane>
++									<TablePane styles="{verticalSpacing:1, horizontalSpacing:1}">
++										<columns>
++											<TablePane.Column />
++											<TablePane.Column />
++											<TablePane.Column />
++											<TablePane.Column />
++											<TablePane.Column />
++											<TablePane.Column />
++										</columns>
++										<rows>
++											<TablePane.Row>
++												<Label text="Flags: " />
++												<Label text=" I - Indexed " />
++												<Label text=" T - Tokenized " />
++												<Label text=" S - Stored " />
++												<Label text=" V - Term Vector " />
++												<Label text=" (o - offsets; p - positions; a - payloads) " />
++											</TablePane.Row>
++											<TablePane.Row>
++												<TablePane.Filler />
++												<Label text=" P - Payloads" />
++												<Label text=" t - Index options" />
++												<Label text=" O - Omit Norms " />
++												<!--Label text="  f - Omit TF " /-->
++												<Label text=" L - Lazy " />
++												<Label text=" B - Binary " />
++											</TablePane.Row>
++										</rows>
++									</TablePane>
++								</FlowPane>
++							</TablePane.Row>
++						</rows>
++					</TablePane>
++				</TablePane.Row>
++				<TablePane.Row height="1*">
++					<Border styles="{padding:1}">
++						<content>
++							<ScrollPane horizontalScrollBarPolicy="fill_to_capacity" styles="{backgroundColor:11}">
++								<view>
++									<TableView bxml:id="docTable">
++										<columns>
++											<TableView.Column name="name"
++																				headerData="%documentsTab_docTable_col1" />
++											<TableView.Column name="itsvopatolb"
++																				headerData="%documentsTab_docTable_col2" />
++											<TableView.Column name="docvaluestype"
++																				headerData="%documentsTab_docTable_col3" />
++											<TableView.Column name="norm"
++																				headerData="%documentsTab_docTable_col4" />
++											<TableView.Column name="value"
++																				headerData="%documentsTab_docTable_col5" width="1*" />
++										</columns>
+ 
+-					</TableView>
+-				</view>
+-				<columnHeader>
+-					<TableViewHeader tableView="$docTable" />
+-				</columnHeader>
+-			</ScrollPane>
+-		</TablePane.Row>
+-	</rows>
++									</TableView>
++								</view>
++								<columnHeader>
++									<TableViewHeader tableView="$docTable" />
++								</columnHeader>
++							</ScrollPane>
++						</content>
++					</Border>
++				</TablePane.Row>
++				<TablePane.Row>
++					<BoxPane orientation="vertical">
++						<Label text="%documentsTab_indexOptionsNote1" styles="{wrapText:true}"/>
++						<Label text="%documentsTab_indexOptionsNote2" styles="{wrapText:true}"/>
++					</BoxPane>
++				</TablePane.Row>
++			</rows>
++		</TablePane>
++	</bottom>
+ </luke:DocumentsTab>
+Index: src/org/apache/lucene/luke/ui/DocumentsTab.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/DocumentsTab.java	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/DocumentsTab.java	(working copy)
+@@ -17,8 +17,9 @@
+  * limitations under the License.
+  */
+ 
+-import java.io.IOException;
++import java.io.*;
+ import java.net.URL;
++import java.util.Arrays;
+ 
+ import org.apache.lucene.document.Document;
+ import org.apache.lucene.document.Field;
+@@ -41,31 +42,25 @@
+ import org.apache.lucene.luke.core.Util;
+ import org.apache.lucene.luke.core.decoders.Decoder;
+ import org.apache.lucene.luke.ui.LukeWindow.LukeMediator;
++import org.apache.lucene.search.DocIdSetIterator;
+ import org.apache.lucene.search.IndexSearcher;
+ import org.apache.lucene.search.Query;
+ import org.apache.lucene.search.TermQuery;
++import org.apache.lucene.search.similarities.DefaultSimilarity;
++import org.apache.lucene.search.similarities.Similarity;
++import org.apache.lucene.search.similarities.TFIDFSimilarity;
++import org.apache.lucene.util.Bits;
+ import org.apache.lucene.util.BytesRef;
+ import org.apache.pivot.beans.BXML;
+ import org.apache.pivot.beans.Bindable;
+-import org.apache.pivot.collections.ArrayList;
+-import org.apache.pivot.collections.HashMap;
+-import org.apache.pivot.collections.List;
+-import org.apache.pivot.collections.Map;
++import org.apache.pivot.collections.*;
+ import org.apache.pivot.util.Resources;
+ import org.apache.pivot.util.concurrent.Task;
+ import org.apache.pivot.util.concurrent.TaskExecutionException;
+ import org.apache.pivot.util.concurrent.TaskListener;
+-import org.apache.pivot.wtk.Action;
+-import org.apache.pivot.wtk.Component;
+-import org.apache.pivot.wtk.Label;
+-import org.apache.pivot.wtk.ListButton;
+-import org.apache.pivot.wtk.TablePane;
+-import org.apache.pivot.wtk.TableView;
+-import org.apache.pivot.wtk.TaskAdapter;
+-import org.apache.pivot.wtk.TextArea;
+-import org.apache.pivot.wtk.TextInput;
++import org.apache.pivot.wtk.*;
+ 
+-public class DocumentsTab extends TablePane implements Bindable {
++public class DocumentsTab extends SplitPane implements Bindable {
+ 
+   private int iNum;
+   @BXML
+@@ -91,6 +86,20 @@
+   @BXML
+   private TextArea decText;
+ 
++  @BXML
++  private PushButton bPos;
++  @BXML
++  private PosAndOffsetsWindow posAndOffsetsWindow;
++
++  @BXML
++  private TermVectorWindow tvWindow;
++
++  @BXML
++  private FieldDataWindow fieldDataWindow;
++
++  @BXML
++  private FieldNormWindow fieldNormWindow;
++
+   private java.util.List<String> fieldNames = null;
+ 
+   // this gets injected by LukeWindow at init
+@@ -99,7 +108,8 @@
+   private Resources resources;
+ 
+   private TermsEnum te;
+-  private DocsAndPositionsEnum td;
++  //private DocsAndPositionsEnum td;
++  private DocsEnum td;
+ 
+   private String fld;
+   private Term lastTerm;
+@@ -218,6 +228,15 @@
+       fieldsList.setSelectedIndex(0);
+     }
+     maxDocs.setText(String.valueOf(ir.maxDoc() - 1));
++
++    bPos.setAction(new Action() {
++      @Override
++      public void perform(Component component) {
++        showPositionsWindow();
++      }
++    });
++
++    addlListenerToDocTable();
+   }
+ 
+   private void showDoc(int incr) {
+@@ -242,6 +261,11 @@
+       }
+       docNum.setText(String.valueOf(iNum));
+ 
++      td = null;
++      tdNum.setText("?");
++      tFreq.setText("?");
++      tdMax.setText("?");
++
+       org.apache.lucene.util.Bits live = ar.getLiveDocs();
+       if (live == null || live.get(iNum)) {
+         Task<Object> populateTableTask = new Task<Object>() {
+@@ -314,7 +338,7 @@
+ 
+   public void popTableWithDoc(int docid, Document doc) {
+     docNum.setText(String.valueOf(docid));
+-    List<Map<String,String>> tableData = new ArrayList<Map<String,String>>();
++    List<Map<String,Object>> tableData = new ArrayList<Map<String,Object>>();
+     docTable.setTableData(tableData);
+ 
+     // putProperty(table, "doc", doc);
+@@ -326,10 +350,9 @@
+ 
+     docNum2.setText(String.valueOf(docid));
+     for (int i = 0; i < indexFields.size(); i++) {
+-      Map<String,String> row = new HashMap<String,String>();
+-
+       IndexableField[] fields = doc.getFields(indexFields.get(i));
+-      if (fields == null) {
++      if (fields == null || fields.length == 0) {
++        Map<String,Object> row = new HashMap<String,Object>();
+         tableData.add(row);
+         addFieldRow(row, indexFields.get(i), null, docid);
+         continue;
+@@ -339,6 +362,7 @@
+         // System.out.println("f.len=" + fields[j].getBinaryLength() +
+         // ", doc.len=" + doc.getBinaryValue(indexFields[i]).length);
+         // }
++        Map<String,Object> row = new HashMap<String,Object>();
+         tableData.add(row);
+         addFieldRow(row, indexFields.get(i), fields[j], docid);
+       }
+@@ -345,7 +369,14 @@
+     }
+   }
+ 
+-  private void addFieldRow(Map<String,String> row, String fName, IndexableField field, int docid) {
++  private static final String FIELDROW_KEY_NAME = "name";
++  private static final String FIELDROW_KEY_FLAGS = "itsvopatolb";
++  private static final String FIELDROW_KEY_DVTYPE = "docvaluestype";
++  private static final String FIELDROW_KEY_NORM = "norm";
++  private static final String FIELDROW_KEY_VALUE = "value";
++  private static final String FIELDROW_KEY_FIELD = "field";
++
++  private void addFieldRow(Map<String,Object> row, String fName, IndexableField field, int docid) {
+     java.util.Map<String,Decoder> decoders = lukeMediator.getDecoders();
+     Decoder defDecoder = lukeMediator.getDefDecoder();
+ 
+@@ -353,29 +384,32 @@
+     // putProperty(row, "field", f);
+     // putProperty(row, "fName", fName);
+ 
+-    row.put("field", fName);
+-    row.put("itsvopfolb", Util.fieldFlags(f));
++    row.put(FIELDROW_KEY_FIELD, field);
+ 
++    row.put(FIELDROW_KEY_NAME, fName);
++    row.put(FIELDROW_KEY_FLAGS, Util.fieldFlags(f, infos.fieldInfo(fName)));
++    row.put(FIELDROW_KEY_DVTYPE, Util.docValuesType(infos.fieldInfo(fName)));
++
+     // if (f == null) {
+     // setBoolean(cell, "enabled", false);
+     // }
+ 
+-    if (f != null) {
++    if (fName != null) {
+       try {
+         FieldInfo info = infos.fieldInfo(fName);
+         if (info.hasNorms()) {
+           NumericDocValues norms = ar.getNormValues(fName);
+-          String val = Long.toString(norms.get(docid));
+-          row.put("norm", String.valueOf(norms.get(docid)));
++          String norm = String.valueOf(norms.get(docid)) + " (" + Util.normType(info) + ")";
++          row.put(FIELDROW_KEY_NORM, norm);
+         } else {
+-          row.put("norm", "---");
++          row.put(FIELDROW_KEY_NORM, "---");
+         }
+       } catch (IOException ioe) {
+         ioe.printStackTrace();
+-        row.put("norm", "!?!");
++        row.put(FIELDROW_KEY_NORM, "!?!");
+       }
+     } else {
+-      row.put("norm", "---");
++      row.put(FIELDROW_KEY_NORM, "---");
+       // setBoolean(cell, "enabled", false);
+     }
+ 
+@@ -395,15 +429,16 @@
+         if (f.fieldType().stored()) {
+           text = dec.decodeStored(f.name(), f);
+         } else {
+-          text = dec.decodeTerm(f.name(), text);
++          //text = dec.decodeTerm(f.name(), text);
++          text = dec.decodeTerm(f.name(), f.binaryValue());
+         }
+       } catch (Throwable e) {
+         // TODO:
+         // setColor(cell, "foreground", Color.RED);
+       }
+-      row.put("value", Util.escape(text));
++      row.put(FIELDROW_KEY_VALUE, Util.escape(text));
+     } else {
+-      row.put("value", "<not present or not stored>");
++      row.put(FIELDROW_KEY_VALUE, "<not present or not stored>");
+       // setBoolean(cell, "enabled", false);
+     }
+   }
+@@ -428,7 +463,7 @@
+         try {
+ 
+           fld = (String) fieldsList.getSelectedItem();
+-          System.out.println("fld:" + fld);
++          //System.out.println("fld:" + fld);
+           Terms terms = MultiFields.getTerms(ir, fld);
+           te = terms.iterator(null);
+           BytesRef term = te.next();
+@@ -472,7 +507,7 @@
+       @Override
+       public void taskExecuted(Task<Object> task) {
+         try {
+-          DocsAndPositionsEnum td = MultiFields.getTermPositionsEnum(ir, null, lastTerm.field(), lastTerm.bytes());
++          DocsEnum td = MultiFields.getTermDocsEnum(ir, null, lastTerm.field(), lastTerm.bytes());
+           td.nextDoc();
+           tdNum.setText("1");
+           DocumentsTab.this.td = td;
+@@ -549,7 +584,7 @@
+ 
+   }
+ 
+-  private void showTerm(final Term t) {
++  protected void showTerm(final Term t) {
+     if (t == null) {
+       // TODO:
+       // showStatus("No terms?!");
+@@ -571,7 +606,8 @@
+     String s = null;
+     boolean decodeErr = false;
+     try {
+-      s = dec.decodeTerm(t.field(), t.text());
++      //s = dec.decodeTerm(t.field(), t.text());
++      s = dec.decodeTerm(t.field(), t.bytes());
+     } catch (Throwable e) {
+       s = e.getMessage();
+       decodeErr = true;
+@@ -580,7 +616,8 @@
+     termText.setText(t.text());
+ 
+     if (!s.equals(t.text())) {
+-      decText.setText(s);
++      String decoded = s + " (by " + dec.toString() + ")";
++      decText.setText(decoded);
+ 
+       if (decodeErr) {
+         // setColor(rawText, "foreground", Color.RED);
+@@ -613,14 +650,11 @@
+ 
+         try {
+           int freq = ir.docFreq(t);
+-          dFreq.setText(String.valueOf(freq));
+-
+           tdMax.setText(String.valueOf(freq));
+         } catch (Exception e) {
+           e.printStackTrace();
+           // TODO:
+           // showStatus(e.getMessage());
+-          dFreq.setText("?");
+         }
+         // ai.setActive(false);
+       }
+@@ -670,17 +704,20 @@
+           String rawString = rawTerm != null ? rawTerm.utf8ToString() : null;
+ 
+           if (te == null || !DocumentsTab.this.fld.equals(fld) || !text.equals(rawString)) {
++            // seek for requested term
+             Terms terms = MultiFields.getTerms(ir, fld);
+             te = terms.iterator(null);
+ 
+             DocumentsTab.this.fld = fld;
+             status = te.seekCeil(new BytesRef(text));
+-            if (status.equals(SeekStatus.FOUND)) {
++            if (status.equals(SeekStatus.FOUND) || status.equals(SeekStatus.NOT_FOUND)) {
++              // precise term or different term after the requested term was found.
+               rawTerm = te.term();
+             } else {
+               rawTerm = null;
+             }
+           } else {
++            // move to next term
+             rawTerm = te.next();
+           }
+           if (rawTerm == null) { // proceed to next field
+@@ -696,7 +733,7 @@
+               te = terms.iterator(null);
+               rawTerm = te.next();
+               DocumentsTab.this.fld = fld;
+-              break;
++              //break;
+             }
+           }
+           if (rawTerm == null) {
+@@ -744,6 +781,7 @@
+         try {
+           Document doc = ir.document(td.docID());
+           docNum.setText(String.valueOf(td.docID()));
++          iNum = td.docID();
+ 
+           tFreq.setText(String.valueOf(td.freq()));
+ 
+@@ -767,6 +805,38 @@
+ 
+   }
+ 
++  private void showPositionsWindow() {
++    try {
++      if (td == null) {
++        Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_docNotSelected"), getWindow());
++      } else {
++        // create new Enum to show positions info
++        DocsAndPositionsEnum pe = MultiFields.getTermPositionsEnum(ir, null, lastTerm.field(), lastTerm.bytes());
++        if (pe == null) {
++          Alert.alert(MessageType.INFO, (String)resources.get("documentsTab_msg_positionNotIndexed"), getWindow());
++        } else {
++          // enumerate docId to the current doc
++          while(pe.docID() != td.docID()) {
++            if (pe.nextDoc() == DocIdSetIterator.NO_MORE_DOCS) {
++              // this must not happen!
++              Alert.alert(MessageType.ERROR, (String)resources.get("documentsTab_msg_noPositionInfo"), getWindow());
++            }
++          }
++          try {
++            posAndOffsetsWindow.initPositionInfo(pe, lastTerm);
++            posAndOffsetsWindow.open(getDisplay(), getWindow());
++          } catch (Exception e) {
++            // TODO:
++            e.printStackTrace();
++          }
++        }
++      }
++    } catch (Exception e) {
++      // TODO
++      e.printStackTrace();
++    }
++  }
++
+   public void showAllTermDoc() {
+     final IndexReader ir = lukeMediator.getIndexInfo().getReader();
+     if (ir == null) {
+@@ -825,4 +895,178 @@
+ 
+   }
+ 
++  private void addlListenerToDocTable() {
++    docTable.getComponentMouseButtonListeners().add(new ComponentMouseButtonListener.Adapter() {
++      @Override
++      public boolean mouseClick(Component component, Mouse.Button button, int i, int i1, int i2) {
++        final Map<String, Object> row = (Map<String, Object>) docTable.getSelectedRow();
++        if (row == null) {
++          System.out.println("No field selected.");
++          return false;
++        }
++        if (button.name().equals(Mouse.Button.RIGHT.name())) {
++          MenuPopup popup = new MenuPopup();
++          Menu menu = new Menu();
++          Menu.Section section = new Menu.Section();
++          Menu.Item item1 = new Menu.Item(resources.get("documentsTab_docTable_popup_menu1"));
++          item1.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              String name = (String)row.get(FIELDROW_KEY_NAME);
++              try {
++                Terms terms = ir.getTermVector(iNum, name);
++                if (terms == null) {
++                  String msg = "DocId: " + iNum + ", field: " + name;
++                  Alert.alert(MessageType.WARNING, "Term vector not avalable for " + msg, getWindow());
++                } else {
++                  showTermVectorWindow(name, terms);
++                }
++              } catch (IOException e) {
++                // TODO:
++                e.printStackTrace();
++              }
++
++            }
++          });
++          Menu.Item item2 = new Menu.Item(resources.get("documentsTab_docTable_popup_menu2"));
++          item2.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              String name = (String)row.get(FIELDROW_KEY_NAME);
++              IndexableField field = (IndexableField)row.get(FIELDROW_KEY_FIELD);
++              if (field == null) {
++                Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_noDataAvailable"), getWindow());
++              } else {
++                showFieldDataWindow(name, field);
++              }
++            }
++          });
++          Menu.Item item3 = new Menu.Item(resources.get("documentsTab_docTable_popup_menu3"));
++          item3.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              String name = (String)row.get(FIELDROW_KEY_NAME);
++              IndexableField field = (IndexableField)row.get(FIELDROW_KEY_FIELD);
++              FieldInfo info = infos.fieldInfo(name);
++              if (field == null) {
++                Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_noDataAvailable"), getWindow());
++              } else if (!info.isIndexed() || !info.hasNorms()) {
++                Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_noNorm"), getWindow());
++              } else {
++                showFieldNormWindow(name);
++              }
++            }
++          });
++          Menu.Item item4 = new Menu.Item(resources.get("documentsTab_docTable_popup_menu4"));
++          item4.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              String name = (String)row.get(FIELDROW_KEY_NAME);
++              IndexableField field = (IndexableField)row.get(FIELDROW_KEY_FIELD);
++              if (ir == null) {
++                Alert.alert(MessageType.ERROR, (String)resources.get("documentsTab_noOrClosedIndex"), getWindow());
++              } else if (field == null) {
++                Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_noDataAvailable"), getWindow());
++              } else {
++                saveFieldData(field);
++              }
++            }
++          });
++          section.add(item1);
++          section.add(item2);
++          section.add(item3);
++          section.add(item4);
++          menu.getSections().add(section);
++          popup.setMenu(menu);
++          popup.open(getWindow(), getMouseLocation().x + 20, getMouseLocation().y + 50);
++          return true;
++        }
++        return false;
++      }
++    });
++
++  }
++
++  private void showTermVectorWindow(String fieldName, Terms tv) {
++    try {
++      tvWindow.initTermVector(fieldName, tv);
++    } catch (IOException e) {
++      // TODO
++      e.printStackTrace();
++    }
++    tvWindow.open(getDisplay(), getWindow());
++  }
++
++  private void showFieldDataWindow(String fieldName, IndexableField field) {
++    fieldDataWindow.initFieldData(fieldName, field);
++    fieldDataWindow.open(getDisplay(), getWindow());
++  }
++
++  private static TFIDFSimilarity defaultSimilarity = new DefaultSimilarity();
++  private void showFieldNormWindow(String fieldName) {
++    if (ar != null) {
++      try {
++        NumericDocValues norms = ar.getNormValues(fieldName);
++        fieldNormWindow.initFieldNorm(iNum, fieldName, norms);
++        fieldNormWindow.open(getDisplay(), getWindow());
++      } catch (Exception e) {
++        Alert.alert(MessageType.ERROR, (String)resources.get("documentsTab_msg_errorNorm"), getWindow());
++        e.printStackTrace();
++      }
++    }
++  }
++
++  private void saveFieldData(IndexableField field) {
++    byte[] data = null;
++    if (field.binaryValue() != null) {
++      BytesRef bytes = field.binaryValue();
++      data = new byte[bytes.length];
++      System.arraycopy(bytes.bytes, bytes.offset, data, 0,
++        bytes.length);
++    }
++    else {
++      try {
++        data = field.stringValue().getBytes("UTF-8");
++      } catch (UnsupportedEncodingException uee) {
++        uee.printStackTrace();
++        data = field.stringValue().getBytes();
++      }
++    }
++    if (data == null || data.length == 0) {
++      Alert.alert(MessageType.WARNING, (String)resources.get("documentsTab_msg_noDataAvailable"), getWindow());
++    }
++
++    final byte[] fieldData = Arrays.copyOf(data, data.length);
++    final FileBrowserSheet fileBrowserSheet = new FileBrowserSheet(FileBrowserSheet.Mode.SAVE_AS);
++    fileBrowserSheet.open(getWindow(), new SheetCloseListener() {
++      @Override
++      public void sheetClosed(Sheet sheet) {
++        if (sheet.getResult()) {
++          Sequence<File> selectedFiles = fileBrowserSheet.getSelectedFiles();
++          File file = selectedFiles.get(0);
++          try {
++            OutputStream os = new FileOutputStream(file);
++            int delta = fieldData.length / 100;
++            if (delta == 0) delta = 1;
++            for (int i = 0; i < fieldData.length; i++) {
++              os.write(fieldData[i]);
++              // TODO: show progress
++              //if (i % delta == 0) {
++              // setInteger(bar, "value", i / delta);
++              //}
++            }
++            os.flush();
++            os.close();
++            Alert.alert(MessageType.INFO, "Saved to " + file.getAbsolutePath(), getWindow());
++          } catch (IOException e) {
++            e.printStackTrace();
++            Alert.alert(MessageType.ERROR, "Can't save to : " + file.getAbsoluteFile(), getWindow());
++          }
++        } else {
++          Alert.alert(MessageType.INFO, "You didn't select anything.", getWindow());
++        }
++
++      }
++    });
++  }
+ }
+Index: src/org/apache/lucene/luke/ui/FieldDataWindow.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/FieldDataWindow.bxml	(revision 0)
++++ src/org/apache/lucene/luke/ui/FieldDataWindow.bxml	(working copy)
+@@ -0,0 +1,57 @@
++<luke:FieldDataWindow bxml:id="fieldData" icon="/img/luke.gif"
++													title="%fieldDataWindow_title" xmlns:bxml="http://pivot.apache.org/bxml"
++													xmlns:luke="org.apache.lucene.luke.ui" xmlns:content="org.apache.pivot.wtk.content"
++													xmlns="org.apache.pivot.wtk">
++	<content>
++		<TablePane styles="{verticalSpacing:10}">
++			<columns>
++				<TablePane.Column width="1*"/>
++			</columns>
++			<rows>
++				<TablePane.Row>
++					<TablePane styles="{verticalSpacing:1,horizontalSpacing:1}">
++						<columns>
++							<TablePane.Column />
++							<TablePane.Column width="1*"/>
++						</columns>
++						<rows>
++							<TablePane.Row>
++								<Label text="Field name:" styles="{font:{bold:true},backgroundColor:'#dce0e7',padding:2}"/>
++								<Label bxml:id="name" text="?" styles="{backgroundColor:'#fcfdfd',padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Field length: " styles="{font:{bold:true},backgroundColor:'#f1f1f1',padding:2}"/>
++								<Label bxml:id="length" text="?" styles="{backgroundColor:11,padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Show content as: " styles="{font:{bold:true},backgroundColor:'#dce0e7',padding:2}"/>
++								<Spinner bxml:id="cDecoder" />
++							</TablePane.Row>
++						</rows>
++					</TablePane>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<Label bxml:id="error" text="%fieldDataWindow_decodeError" visible="false"
++								 styles="{color:'red',padding:2,wrapText:true}" preferredWidth="500"/>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<Border styles="{padding:1}">
++						<ScrollPane>
++							<TextArea bxml:id="data" preferredWidth="500" preferredHeight="250" editable="false"/>
++						</ScrollPane>
++					</Border>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<BoxPane orientation="horizontal" styles="{horizontalAlignment:'right'}">
++						<PushButton buttonData="%label_ok"
++												ButtonPressListener.buttonPressed="fieldData.close()">
++						</PushButton>
++					</BoxPane>
++				</TablePane.Row>
++			</rows>
++		</TablePane>
++	</content>
++</luke:FieldDataWindow>
+\ No newline at end of file
+
+Property changes on: src/org/apache/lucene/luke/ui/FieldDataWindow.bxml
+___________________________________________________________________
+Added: svn:mime-type
+## -0,0 +1 ##
++text/xml
+\ No newline at end of property
+Index: src/org/apache/lucene/luke/ui/FieldDataWindow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/FieldDataWindow.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/FieldDataWindow.java	(working copy)
+@@ -0,0 +1,222 @@
++package org.apache.lucene.luke.ui;
++
++import org.apache.lucene.analysis.payloads.PayloadHelper;
++import org.apache.lucene.document.DateTools;
++import org.apache.lucene.document.Field;
++import org.apache.lucene.index.IndexableField;
++import org.apache.lucene.luke.core.Util;
++import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.NumericUtils;
++import org.apache.pivot.beans.BXML;
++import org.apache.pivot.beans.Bindable;
++import org.apache.pivot.collections.ArrayList;
++import org.apache.pivot.collections.List;
++import org.apache.pivot.collections.Map;
++import org.apache.pivot.serialization.SerializationException;
++import org.apache.pivot.util.Resources;
++import org.apache.pivot.wtk.*;
++
++import java.io.UnsupportedEncodingException;
++import java.net.URL;
++import java.util.Date;
++
++public class FieldDataWindow extends Dialog implements Bindable {
++
++  @BXML
++  private Label name;
++  @BXML
++  private Label length;
++  @BXML
++  private Spinner cDecoder;
++  @BXML
++  private Label error;
++  @BXML
++  private TextArea data;
++
++  private Resources resources;
++
++  private IndexableField field;
++
++  @Override
++  public void initialize(Map<String, Object> map, URL url, Resources resources) {
++    this.resources = resources;
++  }
++
++  public void initFieldData(String fieldName, IndexableField field) {
++    this.field = field;
++
++    setContentDecoders();
++
++    name.setText(fieldName);
++    ContentDecoder dec = ContentDecoder.defDecoder();
++    dec.decode(field);
++    data.setText(String.valueOf(dec.value));
++    length.setText(Integer.toString(dec.len));
++  }
++
++  private void setContentDecoders() {
++    ArrayList<Object> decoders = new ArrayList<Object>();
++    ContentDecoder[] contentDecoders = ContentDecoder.values();
++    for (int i = contentDecoders.length - 1; i >= 0; i--) {
++      decoders.add(contentDecoders[i]);
++    }
++    cDecoder.setSpinnerData(decoders);
++    cDecoder.setSelectedItem(ContentDecoder.STRING_UTF8);
++
++    cDecoder.getSpinnerSelectionListeners().add(new SpinnerSelectionListener.Adapter() {
++      @Override
++      public void selectedItemChanged(Spinner spinner, Object o) {
++        ContentDecoder dec = (ContentDecoder) spinner.getSelectedItem();
++        if (dec == null) {
++          dec = ContentDecoder.defDecoder();
++        }
++        dec.decode(field);
++        data.setText(dec.value);
++        length.setText(Integer.toString(dec.len));
++        if (dec.warn) {
++          error.setVisible(true);
++          try {
++            data.setStyles("{color:'#bdbdbd'}");
++          } catch (SerializationException e) {
++            e.printStackTrace();
++          }
++          data.setEnabled(false);
++        } else {
++          error.setVisible(false);
++          try {
++            data.setStyles("{color:'#000000'}");
++          } catch (SerializationException e) {
++            e.printStackTrace();
++          }
++          data.setEnabled(true);
++        }
++      }
++    });
++  }
++
++
++  enum ContentDecoder {
++    STRING_UTF8("String UTF-8"),
++    STRING("String default enc."),
++    HEXDUMP("Hexdump"),
++    DATETIME("Date / Time"),
++    NUMERIC("Numeric"),
++    LONG("Long (prefix-coded)"),
++    ARRAY_OF_INT("Array of int"),
++    ARRAY_OF_FLOAT("Array of float");
++
++    private String strExpr;
++    ContentDecoder(String strExpr) {
++      this.strExpr = strExpr;
++    }
++
++    @Override
++    public String toString() {
++      return strExpr;
++    }
++
++    public static ContentDecoder defDecoder() {
++      return STRING_UTF8;
++    }
++
++    String value = "";  // decoded value
++    int len;       // length of decoded value
++    boolean warn;  // set to true if decode failed
++
++    public void decode(IndexableField field) {
++      if (field == null) {
++        return ;
++      }
++      warn = false;
++      byte[] data = null;
++      if (field.binaryValue() != null) {
++        BytesRef bytes = field.binaryValue();
++        data = new byte[bytes.length];
++        System.arraycopy(bytes.bytes, bytes.offset, data, 0,
++          bytes.length);
++      }
++      else if (field.stringValue() != null) {
++        try {
++          data = field.stringValue().getBytes("UTF-8");
++        } catch (UnsupportedEncodingException uee) {
++          warn = true;
++          uee.printStackTrace();
++          data = field.stringValue().getBytes();
++        }
++      }
++      if (data == null) data = new byte[0];
++
++      switch(this) {
++        case STRING_UTF8:
++          value = field.stringValue();
++          if (value != null) len = value.length();
++          break;
++        case STRING:
++          value = new String(data);
++          len = value.length();
++          break;
++        case HEXDUMP:
++          value = Util.bytesToHex(data, 0, data.length, true);
++          len = data.length;
++          break;
++        case DATETIME:
++          try {
++            Date d = DateTools.stringToDate(field.stringValue());
++            value = d.toString();
++            len = 1;
++          } catch (Exception e) {
++            warn = true;
++            value = Util.bytesToHex(data, 0, data.length, true);
++          }
++          break;
++        case NUMERIC:
++          if (field.numericValue() != null) {
++            value = field.numericValue().toString() + " (" + field.numericValue().getClass().getSimpleName() + ")";
++          } else {
++            warn = true;
++            value = Util.bytesToHex(data, 0, data.length, true);
++          }
++          break;
++        case LONG:
++          try {
++            long num = NumericUtils.prefixCodedToLong(new BytesRef(field.stringValue()));
++            value = String.valueOf(num);
++            len = 1;
++          } catch (Exception e) {
++            warn = true;
++            value = Util.bytesToHex(data, 0, data.length, true);
++          }
++          break;
++        case ARRAY_OF_INT:
++          if (data.length % 4 == 0) {
++            len = data.length / 4;
++            StringBuilder sb = new StringBuilder();
++            for (int k = 0; k < data.length; k += 4) {
++              if (k > 0) sb.append(',');
++              sb.append(String.valueOf(PayloadHelper.decodeInt(data, k)));
++            }
++            value = sb.toString();
++          } else {
++            warn = true;
++            value = Util.bytesToHex(data, 0, data.length, true);
++          }
++          break;
++        case ARRAY_OF_FLOAT:
++          if (data.length % 4 == 0) {
++            len = data.length / 4;
++            StringBuilder sb = new StringBuilder();
++            for (int k = 0; k < data.length; k += 4) {
++              if (k > 0) sb.append(',');
++              sb.append(String.valueOf(PayloadHelper.decodeFloat(data, k)));
++            }
++            value = sb.toString();
++          } else {
++            warn = true;
++            value = Util.bytesToHex(data, 0, data.length, true);
++          }
++          break;
++      }
++    }
++
++  }
++}
+Index: src/org/apache/lucene/luke/ui/FieldNormWindow.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/FieldNormWindow.bxml	(revision 0)
++++ src/org/apache/lucene/luke/ui/FieldNormWindow.bxml	(working copy)
+@@ -0,0 +1,75 @@
++<luke:FieldNormWindow bxml:id="fieldNorm" icon="/img/luke.gif"
++											 title="%fieldNormWindow_title" xmlns:bxml="http://pivot.apache.org/bxml"
++											 xmlns:luke="org.apache.lucene.luke.ui" xmlns:content="org.apache.pivot.wtk.content"
++											 xmlns="org.apache.pivot.wtk">
++	<content>
++		<TablePane styles="{verticalSpacing:10}">
++			<columns>
++				<TablePane.Column width="1*"/>
++			</columns>
++			<rows>
++				<TablePane.Row>
++					<TablePane styles="{verticalSpacing:5,horizontalSpacing:5}">
++						<columns>
++							<TablePane.Column />
++							<TablePane.Column width="1*"/>
++						</columns>
++						<rows>
++							<TablePane.Row>
++								<Label text="Field name: " />
++								<Label bxml:id="field" text="?" styles="{font:{bold:true}}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Field norm: " />
++								<Label bxml:id="normVal" text="?" styles="{font:{bold:true}}"/>
++							</TablePane.Row>
++						</rows>
++					</TablePane>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<Separator/>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<BoxPane orientation="vertical" styles="{fill:true}">
++						<Label text="%fieldNormWindow_simClass"/>
++						<BoxPane styles="{fill:true}">
++							<TextInput bxml:id="simclass" preferredWidth="400"/>
++							<PushButton bxml:id="refreshButton">
++								<buttonData>
++									<content:ButtonData icon="/img/refresh.png" />
++								</buttonData>
++							</PushButton>
++						</BoxPane>
++						<Label bxml:id="simErr" text="" styles="{color:'red'}" visible="false"/>
++					  <TablePane styles="{verticalSpacing:5,horizontalSpacing:5}">
++							<columns>
++								<TablePane.Column />
++								<TablePane.Column width="1*"/>
++							</columns>
++							<rows>
++								<TablePane.Row>
++									<Label text="%fieldNormWindow_otherNorm"/>
++									<TextInput bxml:id="otherNorm" />
++								</TablePane.Row>
++								<TablePane.Row>
++									<Label text="%fieldNormWindow_encNorm"/>
++									<Label bxml:id="encNorm" text="?"/>
++								</TablePane.Row>
++							</rows>
++						</TablePane>
++					</BoxPane>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<BoxPane orientation="horizontal" styles="{horizontalAlignment:'right'}">
++						<PushButton buttonData="%label_ok"
++												ButtonPressListener.buttonPressed="fieldNorm.close()">
++						</PushButton>
++					</BoxPane>
++				</TablePane.Row>
++			</rows>
++		</TablePane>
++	</content>
++</luke:FieldNormWindow>
+\ No newline at end of file
+
+Property changes on: src/org/apache/lucene/luke/ui/FieldNormWindow.bxml
+___________________________________________________________________
+Added: svn:mime-type
+## -0,0 +1 ##
++text/xml
+\ No newline at end of property
+Index: src/org/apache/lucene/luke/ui/FieldNormWindow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/FieldNormWindow.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/FieldNormWindow.java	(working copy)
+@@ -0,0 +1,122 @@
++package org.apache.lucene.luke.ui;
++
++import org.apache.lucene.index.NumericDocValues;
++import org.apache.lucene.luke.core.Util;
++import org.apache.lucene.search.similarities.DefaultSimilarity;
++import org.apache.lucene.search.similarities.Similarity;
++import org.apache.lucene.search.similarities.TFIDFSimilarity;
++import org.apache.pivot.beans.BXML;
++import org.apache.pivot.beans.Bindable;
++import org.apache.pivot.collections.Map;
++import org.apache.pivot.util.Resources;
++import org.apache.pivot.wtk.*;
++
++import java.net.URL;
++
++public class FieldNormWindow extends Dialog implements Bindable {
++
++  @BXML
++  private Label field;
++  @BXML
++  private Label normVal;
++  @BXML
++  private TextInput simclass;
++  @BXML
++  private Label simErr;
++  @BXML
++  private PushButton refreshButton;
++  @BXML
++  private TextInput otherNorm;
++  @BXML
++  private Label encNorm;
++
++  private Resources resources;
++
++  private String fieldName;
++
++  private static TFIDFSimilarity defaultSimilarity = new DefaultSimilarity();
++
++  @Override
++  public void initialize(Map<String, Object> map, URL url, Resources resources) {
++    this.resources = resources;
++  }
++
++  public void initFieldNorm(int docId, String fieldName, NumericDocValues norms) throws Exception {
++    this.fieldName = fieldName;
++    TFIDFSimilarity sim = defaultSimilarity;
++    byte curBVal = (byte) norms.get(docId);
++    float curFVal = Util.decodeNormValue(curBVal, fieldName, sim);
++    field.setText(fieldName);
++    normVal.setText(Float.toString(curFVal));
++    simclass.setText(sim.getClass().getName());
++    otherNorm.setText(Float.toString(curFVal));
++    encNorm.setText(Float.toString(curFVal) + " (0x" + Util.byteToHex(curBVal) + ")");
++
++    refreshButton.setAction(new Action() {
++      @Override
++      public void perform(Component component) {
++        changeNorms();
++      }
++    });
++    otherNorm.getTextInputContentListeners().add(new TextInputContentListener.Adapter(){
++      @Override
++      public void textChanged(TextInput textInput) {
++        changeNorms();
++      }
++    });
++  }
++
++  private void changeNorms() {
++    String simClassString = simclass.getText();
++
++    Similarity sim = createSimilarity(simClassString);
++    TFIDFSimilarity s = null;
++    if (sim != null && (sim instanceof TFIDFSimilarity)) {
++      s = (TFIDFSimilarity)sim;
++    } else {
++      s = defaultSimilarity;
++    }
++    if (s == null) {
++      s = defaultSimilarity;
++    }
++    //setString(sim, "text", s.getClass().getName());
++    simclass.setText(s.getClass().getName());
++    try {
++      float newFVal = Float.parseFloat(otherNorm.getText());
++      long newBVal = Util.encodeNormValue(newFVal, fieldName, s);
++      float encFVal = Util.decodeNormValue(newBVal, fieldName, s);
++      encNorm.setText(String.valueOf(encFVal) + " (0x" + Util.byteToHex((byte) (newBVal & 0xFF)) + ")");
++    } catch (Exception e) {
++      // TODO:
++      e.printStackTrace();
++    }
++  }
++
++  public Similarity createSimilarity(String simClass) {
++    //Object ckSimDef = find(srchOpts, "ckSimDef");
++    //Object ckSimSweet = find(srchOpts, "ckSimSweet");
++    //Object ckSimOther = find(srchOpts, "ckSimOther");
++    //Object simClass = find(srchOpts, "simClass");
++    //Object ckSimCust = find(srchOpts, "ckSimCust");
++    //if (getBoolean(ckSimDef, "selected")) {
++    //      return new DefaultSimilarity();
++    //} else if (getBoolean(ckSimSweet, "selected")) {
++    //  return new SweetSpotSimilarity();
++    //} else if (getBoolean(ckSimOther, "selected")) {
++    try {
++      Class clazz = Class.forName(simClass);
++      if (Similarity.class.isAssignableFrom(clazz)) {
++        Similarity sim = (Similarity) clazz.newInstance();
++        simErr.setVisible(false);
++        return sim;
++      } else {
++        simErr.setText("Not a subclass of Similarity: " + clazz.getName());
++        simErr.setVisible(true);
++      }
++    } catch (Exception e) {
++      simErr.setText("Invalid similarity class " + simClass + ", using DefaultSimilarity.");
++      simErr.setVisible(true);
++    }
++    return new DefaultSimilarity();
++  }
++}
+Index: src/org/apache/lucene/luke/ui/LukeApplication_en.json
+===================================================================
+--- src/org/apache/lucene/luke/ui/LukeApplication_en.json	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/LukeApplication_en.json	(working copy)
+@@ -26,6 +26,9 @@
+  sandstoneTheme: "Sandstone Theme",
+  skyTheme: "Sky Theme",
+  navyTheme: "Navy Theme",
++
++ label_ok: "OK",
++ label_clipboard: "Copy to Clipboard",
+  
+  lukeInitWindow_title: "Path to index directory:",
+  lukeInitWindow_path: "Path:",
+@@ -58,9 +61,10 @@
+  overviewTab_userData: "Current commit user data:",
+  
+  overviewTab_fieldsAndTermCounts: "Available fields and term counts per field:",
+- overviewTab_topRankingTerms: "Top Ranking Terms (Right click for more options)",
++ overviewTab_topRankingTerms: "Top Ranking Terms (Select a row and right click for more options)",
+  overviewTab_decoderWarn: "Tokens marked in red indicate decoding errors, likely due to a mismatched decoder.",
+  overviewTab_fieldSelect: "Select fields from the list below, and press button to view top terms in these fields. No selection means all fields.",
++ overviewTab_fieldsHintDecoder: "Hint: Double click 'Decoder' column, select decoder class, and press Enter to set the suitable decoder.",
+  overviewTab_topTermsHint: "Hint: use Shift-Click to select ranges, or Ctrl-Click to select multiple fields (or unselect all).",
+  overviewTab_showTopTerms: "Show top terms >>",
+  
+@@ -68,28 +72,63 @@
+  overviewTab_topTermsTable_col2: "DF",
+  overviewTab_topTermsTable_col3: "Field",
+  overviewTab_topTermsTable_col4: "Text",
++
++ overviewTab_topTermTable_popup_menu1: "Browse term docs",
++ overviewTab_topTermTable_popup_menu2: "Show all term docs",
++ overviewTab_topTermTable_popup_menu3: "Copy to clipboard",
+  
+  documentsTab_noOrClosedIndex: "FAILED: No index, or index is closed. Reopen it.",
+  documentsTab_docNumOutsideRange: "Document number outside valid range.",
+  documentsTab_browseByDocNum: "Browse by document number:",
+  documentsTab_browseByTerm: "Browse by term:",
++ documentsTab_selectField: "Select a field from the spinner below, press Next to browse terms.",
+  documentsTab_enterTermHint: "(Hint: enter a substring and press Next to start at the nearest term).",
+  documentsTab_firstTerm: "First Term",
+  documentsTab_term: "Term:",
+  documentsTab_decodedValue: "Decoded value:",
+- documentsTab_browseDocsWithTerm: "Browse documents with this term",
++ documentsTab_browseDocsWithTerm: "Browse documents with this term:",
++ documentsTab_selectTerm: "After select term, press Next to browse docs with the term.",
+  documentsTab_showAllDocs: "Show All Docs",
+  documentsTab_deleteAllDocs: "Delete All Docs",
+  documentsTab_document: "Document:",
+  documentsTab_firstDoc: "First Doc",
+  documentsTab_termFreqInDoc: "Term freq in this doc:",
+- documentsTab_showPositions: "Show Positions",
+- 
++ documentsTab_showPositions: "Show Positions and Offsets",
++ documentsTab_indexOptionsNote1: "Note: flag 't - Index options' means, 1: DOCS_ONLY; 2:DOCS_AND_FREQS; 3: DOCS_AND_FREQS_AND_POSITIONS; 4: DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS.",
++ documentsTab_indexOptionsNote2: "(See Javadocs about FieldInfo.IndexOptions for more info.)",
++
+  documentsTab_docTable_col1: "Field",
+- documentsTab_docTable_col2: "ITSVopfOLB",
+- documentsTab_docTable_col3: "Norm",
+- documentsTab_docTable_col4: "Value",
+- 
++ documentsTab_docTable_col2: "ITSVopaPtOLB",
++ documentsTab_docTable_col3: "DocValues Type",
++ documentsTab_docTable_col4: "Norm (Norm Type)",
++ documentsTab_docTable_col5: "Value",
++
++ documentsTab_docTable_popup_menu1: "Field's Term Vector",
++ documentsTab_docTable_popup_menu2: "Show Full Text",
++ documentsTab_docTable_popup_menu3: "Set Norm",
++ documentsTab_docTable_popup_menu4: "Save Field",
++
++ documentsTab_msg_docNotSelected: "Please select a term and a document for showing term positions.",
++ documentsTab_msg_positionNotIndexed: "Positions are not indexed for this term.",
++ documentsTab_msg_noPositionInfo: "No positions info ???",
++ documentsTab_msg_noDataAvailable: "No data available for this field.",
++ documentsTab_msg_noNorm: "Cannot examine norm value - this field is not indexed.",
++ documentsTab_msg_errorNorm: "Error reading norm: ",
++ documentsTab_msg_cantOverwriteDir: "Can't overwrite a directory.",
++
++ posAndOffsetsWindow_title: "Term Positions and Offsets",
++
++ termVectorWindow_title: "Term Vector",
++ termVectorWindow_field: "Term vector for the field: ",
++
++ fieldDataWindow_title: "Field Data",
++ fieldDataWindow_decodeError: "Some values could not be properly represented in this format. They are marked in grey and presented as a hex dump.",
++
++ fieldNormWindow_title: "Field Norm",
++ fieldNormWindow_simClass: "Encode other field norm using this TFIDFSimilarity (full class name): ",
++ fieldNormWindow_otherNorm: "Enter norm value: ",
++ fieldNormWindow_encNorm: "Encoded value rounded to: ",
++
+  searchTab_searchPrompt: "Enter search expression here:",
+  searchTab_update: "Update",
+  searchTab_explainStructure: "Explain Structure",
+Index: src/org/apache/lucene/luke/ui/LukeWindow.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/LukeWindow.bxml	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/LukeWindow.bxml	(working copy)
+@@ -103,7 +103,7 @@
+ 								</content>
+ 							</Border>
+ 
+-							<Border>
++							<Border styles="{backgroundColor:11,thickness:0}">
+ 								<TabPane.tabData>
+ 									<content:ButtonData icon="/img/docs.gif"
+ 										text="%lukeWindow_documentsTabText" />
+@@ -160,7 +160,10 @@
+ 					</TabPane>
+ 				</TablePane.Row>
+ 				<TablePane.Row>
+-					<Label bxml:id="statusLabel" text="" styles="{padding:2}" />
++					<BoxPane>
++						<Label bxml:id="indexName" text="" styles="{padding:2}"/>
++						<Label bxml:id="statusLabel" text="" styles="{padding:2}" />
++					</BoxPane>
+ 				</TablePane.Row>
+ 			</rows>
+ 		</TablePane>
+Index: src/org/apache/lucene/luke/ui/LukeWindow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/LukeWindow.java	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/LukeWindow.java	(working copy)
+@@ -23,7 +23,6 @@
+ import java.lang.reflect.Constructor;
+ import java.net.URL;
+ import java.util.Arrays;
+-import java.util.HashMap;
+ import java.util.HashSet;
+ 
+ import org.apache.lucene.analysis.Analyzer;
+@@ -99,6 +98,8 @@
+   @BXML
+   private LukeInitWindow lukeInitWindow;
+   @BXML
++  private TabPane tabPane;
++  @BXML
+   private FilesTab filesTab;
+   @BXML
+   private DocumentsTab documentsTab;
+@@ -108,6 +109,8 @@
+   private OverviewTab overviewTab;
+   @BXML
+   private AnalyzersTab analyzersTab;
++  @BXML
++  private Label indexName;
+ 
+   private LukeMediator lukeMediator = new LukeMediator();
+ 
+@@ -439,6 +442,7 @@
+ 
+       // initPlugins();
+       showStatus("Index successfully open.");
++      indexName.setText("Index path: " + indexPath);
+     } catch (Exception e) {
+       e.printStackTrace();
+       errorMsg(e.getMessage());
+@@ -622,8 +626,13 @@
+       setComponentColor(component, "scrollButtonBackgroundColor", theme[2]);
+       setComponentColor(component, "borderColor", theme[3]);
+     } else if (component instanceof PushButton || component instanceof ListButton) {
+-      component.getComponentMouseButtonListeners().add(mouseButtonPressedListener);
+-      component.getComponentMouseListeners().add(mouseMoveListener);
++      // Listeners are added at start-up time only.
++      if (component.getComponentMouseButtonListeners().isEmpty()) {
++        component.getComponentMouseButtonListeners().add(mouseButtonPressedListener);
++      }
++      if (component.getComponentMouseListeners().isEmpty()) {
++        component.getComponentMouseListeners().add(mouseMoveListener);
++      }
+       setComponentColor(component, "color", theme[1]);
+       setComponentColor(component, "backgroundColor", theme[0]);
+       setComponentColor(component, "borderColor", theme[3]);
+@@ -679,7 +688,7 @@
+ 
+   private Directory directory;
+ 
+-  class LukeMediator {
++  public class LukeMediator {
+ 
+     // populated by LukeWindow#openIndex
+     private IndexInfo indexInfo;
+@@ -700,6 +709,14 @@
+       return overviewTab;
+     }
+ 
++    public DocumentsTab getDocumentsTab() {
++      return documentsTab;
++    }
++
++    public TabPane getTabPane() {
++      return tabPane;
++    }
++
+     public LukeWindow getLukeWindow() {
+       return LukeWindow.this;
+     }
+Index: src/org/apache/lucene/luke/ui/OverviewTab.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/OverviewTab.bxml	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/OverviewTab.bxml	(working copy)
+@@ -148,9 +148,12 @@
+ 											</columns>
+ 											<rows>
+ 												<TablePane.Row height="-1">
+-													<Label styles="{backgroundColor:11,padding:2}" text="%overviewTab_fieldSelect" />
++													<Label styles="{backgroundColor:11,padding:2,wrapText:true}" text="%overviewTab_fieldSelect" />
+ 												</TablePane.Row>
+ 												<TablePane.Row height="-1">
++													<Label styles="{backgroundColor:11,padding:2,wrapText:true}" text="%overviewTab_fieldsHintDecoder" />
++												</TablePane.Row>
++												<TablePane.Row height="-1">
+ 													<Label styles="{backgroundColor:11,font:{bold:true}}"
+ 														text="%overviewTab_fieldsAndTermCounts" />
+ 												</TablePane.Row>
+Index: src/org/apache/lucene/luke/ui/OverviewTab.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/OverviewTab.java	(revision 1655665)
++++ src/org/apache/lucene/luke/ui/OverviewTab.java	(working copy)
+@@ -21,19 +21,16 @@
+ import java.text.NumberFormat;
+ import java.util.Collections;
+ 
+-import org.apache.lucene.index.AtomicReaderContext;
+-import org.apache.lucene.index.DirectoryReader;
+-import org.apache.lucene.index.IndexCommit;
+-import org.apache.lucene.index.IndexReader;
+-import org.apache.lucene.index.SegmentReader;
+-import org.apache.lucene.luke.core.FieldTermCount;
++import org.apache.lucene.index.*;
++import org.apache.lucene.luke.core.*;
+ import org.apache.lucene.luke.core.HighFreqTerms;
+-import org.apache.lucene.luke.core.IndexInfo;
+-import org.apache.lucene.luke.core.TableComparator;
+ import org.apache.lucene.luke.core.TermStats;
+-import org.apache.lucene.luke.core.decoders.Decoder;
++import org.apache.lucene.luke.core.decoders.*;
+ import org.apache.lucene.luke.ui.LukeWindow.LukeMediator;
++import org.apache.lucene.luke.ui.util.FieldsTableRow;
++import org.apache.lucene.luke.ui.util.TableComparator;
+ import org.apache.lucene.store.Directory;
++import org.apache.lucene.util.BytesRef;
+ import org.apache.pivot.beans.BXML;
+ import org.apache.pivot.beans.Bindable;
+ import org.apache.pivot.collections.*;
+@@ -43,6 +40,7 @@
+ import org.apache.pivot.util.concurrent.TaskExecutionException;
+ import org.apache.pivot.util.concurrent.TaskListener;
+ import org.apache.pivot.wtk.*;
++import org.apache.pivot.wtk.content.TableViewRowEditor;
+ 
+ 
+ public class OverviewTab extends SplitPane implements Bindable {
+@@ -230,7 +228,6 @@
+ 
+             iTerms.setText(String.valueOf(numTerms));
+             initFieldList(null, null);
+-
+           } catch (Exception e) {
+             // showStatus("ERROR: can't count terms per field");
+             numTerms = -1;
+@@ -261,6 +258,7 @@
+       };
+ 
+       fListTask.execute(new TaskAdapter<String>(taskListener));
++      clearFieldsTableStatus();
+ 
+       String sDel = ir.hasDeletions() ? "Yes (" + ir.numDeletedDocs() + ")" : "No";
+       IndexCommit ic = ir instanceof DirectoryReader ? ((DirectoryReader) ir).getIndexCommit() : null;
+@@ -347,16 +345,24 @@
+ 
+     Sequence<?> fields = fieldsTable.getSelectedRows();
+ 
+-    String[] flds = null;
++    final java.util.Map<String, Decoder> fldDecMap = new java.util.HashMap<String, Decoder>();
+     if (fields == null || fields.getLength() == 0) {
+-      flds = indexInfo.getFieldNames().toArray(new String[0]);
++      // no fields selected
++      for (String fld : indexInfo.getFieldNames()) {
++        Decoder dec = lukeMediator.getDecoders().get(fld);
++        if (dec == null) {
++          dec = lukeMediator.getDefDecoder();
++        }
++        fldDecMap.put(fld, dec);
++      }
+     } else {
+-      flds = new String[fields.getLength()];
++      // some fields selected
+       for (int i = 0; i < fields.getLength(); i++) {
+-        flds[i] = ((Map<String,String>) fields.get(i)).get("name");
++        String fld = ((FieldsTableRow)fields.get(i)).getName();
++        Decoder dec = ((FieldsTableRow)fields.get(i)).getDecoder();
++        fldDecMap.put(fld, dec);
+       }
+     }
+-    final String[] fflds = flds;
+ 
+     tTable.setTableData(new ArrayList(0));
+ 
+@@ -387,6 +393,7 @@
+       public void taskExecuted(Task<Object> task) {
+         // this must happen here rather than in the task because it must happen in the UI dispatch thread
+         try {
++          final String[] fflds = fldDecMap.keySet().toArray(new String[0]);
+           TermStats[] topTerms = HighFreqTerms.getHighFreqTerms(ir, ndoc, fflds);
+ 
+           List<Map<String,String>> tableData = new ArrayList<Map<String,String>>();
+@@ -409,12 +416,11 @@
+ 
+             row.put("field", topTerms[i].field);
+ 
+-            Decoder dec = lukeMediator.getDecoders().get(topTerms[i].field);
+-            if (dec == null)
+-              dec = lukeMediator.getDefDecoder();
++            Decoder dec = fldDecMap.get(topTerms[i].field);
++
+             String s;
+             try {
+-              s = dec.decodeTerm(topTerms[i].field, topTerms[i].termtext.utf8ToString());
++              s = dec.decodeTerm(topTerms[i].field, topTerms[i].termtext);
+             } catch (Throwable e) {
+               // e.printStackTrace();
+               s = topTerms[i].termtext.utf8ToString();
+@@ -422,6 +428,8 @@
+               // setColor(cell, "foreground", Color.RED);
+             }
+             row.put("text", s);
++            // hidden field. would be used when the user select 'Browse term docs' menu at top terms table.
++            row.put("rawterm", topTerms[i].termtext.utf8ToString());
+             tableData.add(row);
+           }
+           tTable.setTableData(tableData);
+@@ -443,8 +451,74 @@
+     };
+ 
+     topTermsTask.execute(new TaskAdapter<Object>(taskListener));
++
++    addListenerToTopTermsTable();
+   }
+ 
++  private void addListenerToTopTermsTable() {
++    // register mouse button listener for more options.
++    tTable.getComponentMouseButtonListeners().add(new ComponentMouseButtonListener.Adapter(){
++      @Override
++      public boolean mouseClick(Component component, Mouse.Button button, int x, int y, int count) {
++        final Map<String, String> row = (Map<String, String>) tTable.getSelectedRow();
++        if (row == null) {
++          System.out.println("No term selected.");
++          return false;
++        }
++        if (button.name().equals(Mouse.Button.RIGHT.name())) {
++          MenuPopup popup = new MenuPopup();
++          Menu menu = new Menu();
++          Menu.Section section1 = new Menu.Section();
++          Menu.Section section2 = new Menu.Section();
++          Menu.Item item1 = new Menu.Item(resources.get("overviewTab_topTermTable_popup_menu1"));
++          item1.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              // 'Browse term docs' menu selected. switch to Documents tab.
++              Term term = new Term(row.get("field"), new BytesRef(row.get("rawterm")));
++              lukeMediator.getDocumentsTab().showTerm(term);
++              // TODO: index access isn't good...
++              lukeMediator.getTabPane().setSelectedIndex(1);
++            }
++          });
++          Menu.Item item2 = new Menu.Item(resources.get("overviewTab_topTermTable_popup_menu2"));
++          item2.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              // 'Show all term docs' menu selected. switch to Search tab.
++              // TODO
++            }
++          });
++          Menu.Item item3 = new Menu.Item(resources.get("overviewTab_topTermTable_popup_menu3"));
++          item3.setAction(new Action() {
++            @Override
++            public void perform(Component component) {
++              // 'Copy to clipboard' menu selected.
++              StringBuilder sb = new StringBuilder();
++              sb.append(row.get("num") + "\t");
++              sb.append(row.get("df") + "\t");
++              sb.append(row.get("field") + "\t");
++              sb.append(row.get("text") + "\t");
++              LocalManifest content = new LocalManifest();
++              content.putText(sb.toString());
++              Clipboard.setContent(content);
++            }
++          });
++          section1.add(item1);
++          section1.add(item2);
++          section2.add(item3);
++          menu.getSections().add(section1);
++          menu.getSections().add(section2);
++          popup.setMenu(menu);
++
++          popup.open(getWindow(), getMouseLocation().x + 20, getMouseLocation().y);
++          return true;
++        }
++        return false;
++      }
++    });
++  }
++
+   private void initFieldList(Object fCombo, Object defFld) {
+     // removeAll(fieldsTable);
+     // removeAll(defFld);
+@@ -454,11 +528,12 @@
+     NumberFormat percentFormat = NumberFormat.getNumberInstance();
+     intCountFormat.setGroupingUsed(true);
+     percentFormat.setMaximumFractionDigits(2);
++    // sort listener
+     fieldsTable.getTableViewSortListeners().add(new TableViewSortListener.Adapter() {
+       @Override
+       public void sortChanged(TableView tableView) {
+         @SuppressWarnings("unchecked")
+-        List<Map<String, String>> tableData = (List<Map<String, String>>) tableView.getTableData();
++        List<FieldsTableRow> tableData = (List<FieldsTableRow>) tableView.getTableData();
+         tableData.setComparator(new TableComparator(tableView));
+       }
+     });
+@@ -465,34 +540,39 @@
+     // default sort : sorted by name in ascending order
+     fieldsTable.setSort("name", SortDirection.ASCENDING);
+ 
+-    for (String s : indexInfo.getFieldNames()) {
+-      Map<String,String> row = new HashMap<String,String>();
++    // row editor for decoders
++    List decoders = new ArrayList();
++    for (Decoder dec : Util.loadDecoders()) {
++      decoders.add(dec);
++    }
++    ListButton decodersButton = new ListButton(decoders);
++    decodersButton.setSelectedItemKey("decoder");
++    TableViewRowEditor rowEditor = new TableViewRowEditor();
++    rowEditor.getCellEditors().put("decoder", decodersButton);
++    fieldsTable.setRowEditor(rowEditor);
+ 
+-      row.put("name", s);
+ 
+-      FieldTermCount ftc = termCounts.get(s);
++    for (String fname : indexInfo.getFieldNames()) {
++      FieldsTableRow row = new FieldsTableRow(lukeMediator);
++      row.setName(fname);
++      FieldTermCount ftc = termCounts.get(fname);
+       if (ftc != null) {
+         long cnt = ftc.termCount;
+-
+-        row.put("termCount", intCountFormat.format(cnt));
+-
++        row.setTermCount(intCountFormat.format(cnt));
+         float pcent = (float) (cnt * 100) / (float) numTerms;
+-
+-        row.put("percent", percentFormat.format(pcent) + " %");
+-
++        row.setPercent(percentFormat.format(pcent) + " %");
+       } else {
+-        row.put("termCount", "0");
+-        row.put("percent", "0.00%");
++        row.setTermCount("0");
++        row.setPercent("0.00%");
+       }
+ 
+-      //tableData.add(row);
+-      List<Map<String, String>> tableData = (List<Map<String, String>>)fieldsTable.getTableData();
++      List<FieldsTableRow> tableData = (List<FieldsTableRow>)fieldsTable.getTableData();
+       tableData.add(row);
+ 
+-      Decoder dec = lukeMediator.getDecoders().get(s);
++      Decoder dec = lukeMediator.getDecoders().get(fname);
+       if (dec == null)
+         dec = lukeMediator.getDefDecoder();
+-      row.put("decoder", dec.toString());
++      row.setDecoder(dec);
+ 
+       // populate combos
+       // Object choice = create("choice");
+@@ -504,9 +584,14 @@
+       // setString(choice, "text", s);
+       // putProperty(choice, "fName", s);
+     }
+-    //fieldsTable.setTableData(tableData);
++
+   }
+ 
++  private void clearFieldsTableStatus() {
++    // clear the fields table view status
++    fieldsTable.clearSelection();
++  }
++
+   private int getNTerms() {
+     final int nTermsInt = nTerms.getSelectedIndex();
+     return nTermsInt;
+Index: src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.bxml	(revision 0)
++++ src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.bxml	(working copy)
+@@ -0,0 +1,77 @@
++<?xml version="1.0" encoding="UTF-8"?>
++
++<luke:PosAndOffsetsWindow bxml:id="posAndOffsets" icon="/img/luke.gif"
++										 title="%posAndOffsetsWindow_title" xmlns:bxml="http://pivot.apache.org/bxml"
++										 xmlns:luke="org.apache.lucene.luke.ui" xmlns:content="org.apache.pivot.wtk.content"
++										 xmlns="org.apache.pivot.wtk">
++	<content>
++		<TablePane styles="{verticalSpacing:10}">
++			<columns>
++				<TablePane.Column width="1*"/>
++			</columns>
++			<rows>
++				<TablePane.Row>
++					<TablePane styles="{verticalSpacing:1,horizontalSpacing:1}">
++						<columns>
++							<TablePane.Column />
++							<TablePane.Column width="1*"/>
++						</columns>
++						<rows>
++							<TablePane.Row>
++								<Label text="Document #" styles="{font:{bold:true},backgroundColor:'#dce0e7',padding:2}"/>
++								<Label bxml:id="docNum" text="?" styles="{backgroundColor:'#fcfdfd',padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Term positions for term: " styles="{font:{bold:true},backgroundColor:'#f1f1f1',padding:2}"/>
++								<Label bxml:id="term" text="?" styles="{backgroundColor:11,padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Term Frequency: " styles="{font:{bold:true},backgroundColor:'#dce0e7',padding:2}"/>
++								<Label bxml:id="tf" text="?" styles="{backgroundColor:'#fcfdfd',padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Offsets: " styles="{font:{bold:true},backgroundColor:'#f1f1f1',padding:2}"/>
++								<Label bxml:id="offsets" text="?" styles="{backgroundColor:11,padding:2}"/>
++							</TablePane.Row>
++							<TablePane.Row>
++								<Label text="Show payload as: " styles="{font:{bold:true},backgroundColor:'#dce0e7',padding:2}"/>
++								<Spinner bxml:id="pDecoder" />
++							</TablePane.Row>
++						</rows>
++					</TablePane>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<Border styles="{padding:1}">
++						<ScrollPane horizontalScrollBarPolicy="fill_to_capacity" styles="{backgroundColor:11}">
++							<view>
++								<TableView bxml:id="posTable" selectMode="multi">
++									<columns>
++										<TableView.Column name="pos"
++																			headerData="Position" width="50"/>
++										<TableView.Column name="offsets"
++																			headerData="Offsets" width="100"/>
++										<TableView.Column name="payloadStr"
++																			headerData="Payload" width="300"/>
++									</columns>
++								</TableView>
++							</view>
++							<columnHeader>
++								<TableViewHeader tableView="$posTable" />
++							</columnHeader>
++						</ScrollPane>
++					</Border>
++				</TablePane.Row>
++
++				<TablePane.Row>
++					<BoxPane orientation="horizontal" styles="{horizontalAlignment:'right'}">
++						<PushButton buttonData="%label_ok"
++												ButtonPressListener.buttonPressed="posAndOffsets.close()">
++						</PushButton>
++						<PushButton bxml:id="posCopyButton" buttonData="%label_clipboard"/>
++					</BoxPane>
++				</TablePane.Row>
++			</rows>
++		</TablePane>
++	</content>
++</luke:PosAndOffsetsWindow>
+\ No newline at end of file
+
+Property changes on: src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.bxml
+___________________________________________________________________
+Added: svn:mime-type
+## -0,0 +1 ##
++text/xml
+\ No newline at end of property
+Index: src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/PosAndOffsetsWindow.java	(working copy)
+@@ -0,0 +1,208 @@
++package org.apache.lucene.luke.ui;
++
++import org.apache.lucene.analysis.payloads.PayloadHelper;
++import org.apache.lucene.index.DocsAndPositionsEnum;
++import org.apache.lucene.index.Term;
++import org.apache.lucene.luke.core.Util;
++import org.apache.lucene.util.BytesRef;
++import org.apache.pivot.beans.BXML;
++import org.apache.pivot.beans.Bindable;
++import org.apache.pivot.collections.ArrayList;
++import org.apache.pivot.collections.List;
++import org.apache.pivot.collections.Map;
++import org.apache.pivot.collections.Sequence;
++import org.apache.pivot.util.Resources;
++import org.apache.pivot.wtk.*;
++
++import java.net.URL;
++
++public class PosAndOffsetsWindow extends Dialog implements Bindable {
++
++  @BXML
++  private TableView posTable;
++  @BXML
++  private Label docNum;
++  @BXML
++  private Label term;
++  @BXML
++  private Label tf;
++  @BXML
++  private Label offsets;
++  @BXML
++  private Spinner pDecoder;
++  @BXML
++  private PushButton posCopyButton;
++
++  private Resources resources;
++
++  private List<PositionAndOffset> tableData;
++
++  @Override
++  public void initialize(Map<String, Object> map, URL url, Resources resources) {
++    this.resources = resources;
++  }
++
++  public void initPositionInfo(DocsAndPositionsEnum pe, Term lastTerm) throws Exception {
++    setPayloadDecoders();
++    tableData = new ArrayList<PositionAndOffset>(getTermPositionAndOffsets(pe));
++    docNum.setText(String.valueOf(pe.docID()));
++    term.setText(lastTerm.field() + ":" + lastTerm.text());
++    tf.setText(String.valueOf(pe.freq()));
++    if (!tableData.isEmpty()) {
++      offsets.setText(String.valueOf(tableData.get(0).hasOffsets));
++    }
++    posTable.setTableData(tableData);
++    addPushButtonListener();
++  }
++
++  private void setPayloadDecoders() {
++    ArrayList<Object> decoders = new ArrayList<Object>();
++    decoders.add(PayloadDecoder.ARRAY_OF_FLOAT);
++    decoders.add(PayloadDecoder.ARRAY_OF_INT);
++    decoders.add(PayloadDecoder.HEXDUMP);
++    decoders.add(PayloadDecoder.STRING);
++    decoders.add(PayloadDecoder.STRING_UTF8);
++    pDecoder.setSpinnerData(decoders);
++    pDecoder.setSelectedItem(PayloadDecoder.STRING_UTF8);
++
++    pDecoder.getSpinnerSelectionListeners().add(new SpinnerSelectionListener.Adapter() {
++      @Override
++      public void selectedItemChanged(Spinner spinner, Object o) {
++        try {
++          for (PositionAndOffset row : tableData) {
++            PayloadDecoder dec = (PayloadDecoder) spinner.getSelectedItem();
++            if (dec == null) {
++              dec = PayloadDecoder.defDecoder();
++            }
++            row.payloadStr = dec.decode(row.payload);
++          }
++          posTable.repaint();  // update table data
++        } catch (Exception e) {
++          // TODO:
++          e.printStackTrace();
++        }
++      }
++    });
++  }
++
++  public class PositionAndOffset {
++    public int pos = -1;
++    public boolean hasOffsets = false;
++    public String offsets = "----";
++    public BytesRef payload = null;
++    public String payloadStr = "----";
++  }
++
++  private PositionAndOffset[] getTermPositionAndOffsets(DocsAndPositionsEnum pe) throws Exception {
++    int freq = pe.freq();
++
++    PositionAndOffset[] res = new PositionAndOffset[freq];
++    for (int i = 0; i < freq; i++) {
++      PositionAndOffset po = new PositionAndOffset();
++      po.pos = pe.nextPosition();
++      if (pe.startOffset() >= 0 && pe.endOffset() >= 0) {
++        // retrieve start and end offsets
++        po.hasOffsets = true;
++        po.offsets = String.valueOf(pe.startOffset()) + " - " + String.valueOf(pe.endOffset());
++      }
++      if (pe.getPayload() != null) {
++        po.payload = pe.getPayload();
++        po.payloadStr = ((PayloadDecoder) pDecoder.getSelectedItem()).decode(pe.getPayload());
++      }
++      res[i] = po;
++    }
++    return res;
++  }
++
++  enum PayloadDecoder {
++    STRING_UTF8("String UTF-8"),
++    STRING("String default enc."),
++    HEXDUMP("Hexdump"),
++    ARRAY_OF_INT("Array of int"),
++    ARRAY_OF_FLOAT("Array of float");
++
++    private String strExpr = null;
++    PayloadDecoder(String expr) {
++      this.strExpr = expr;
++    }
++
++    @Override
++    public String toString() {
++      return strExpr;
++    }
++
++    public static PayloadDecoder defDecoder() {
++      return STRING_UTF8;
++    }
++
++    public String decode(BytesRef payload) {
++      String val = "----";
++      StringBuilder sb = null;
++      if (payload == null) {
++        return val;
++      }
++      switch(this) {
++        case STRING_UTF8:
++          try {
++            val = new String(payload.bytes, payload.offset, payload.length, "UTF-8");
++          } catch (Exception e) {
++            e.printStackTrace();
++            val = new String(payload.bytes, payload.offset, payload.length);
++          }
++          break;
++        case STRING:
++          val = new String(payload.bytes, payload.offset, payload.length);
++          break;
++        case HEXDUMP:
++          val = Util.bytesToHex(payload.bytes, payload.offset, payload.length, false);
++          break;
++        case ARRAY_OF_INT:
++          sb = new StringBuilder();
++          for (int k = payload.offset; k < payload.offset + payload.length; k += 4) {
++            if (k > 0) sb.append(',');
++            sb.append(String.valueOf(PayloadHelper.decodeInt(payload.bytes, k)));
++          }
++          val = sb.toString();
++          break;
++        case ARRAY_OF_FLOAT:
++          sb = new StringBuilder();
++          for (int k = payload.offset; k < payload.offset + payload.length; k += 4) {
++            if (k > 0) sb.append(',');
++            sb.append(String.valueOf(PayloadHelper.decodeFloat(payload.bytes, k)));
++          }
++          val = sb.toString();
++          break;
++      }
++      return val;
++    }
++  }
++
++  private void addPushButtonListener() {
++
++    posCopyButton.getButtonPressListeners().add(new ButtonPressListener() {
++      @Override
++      public void buttonPressed(Button button) {
++        // fired when 'Copy to Clipboard' button pressed
++        Sequence<PositionAndOffset> selectedRows = (Sequence<PositionAndOffset>) posTable.getSelectedRows();
++        if (selectedRows == null || selectedRows.getLength() == 0) {
++          Alert.alert(MessageType.INFO, "No rows selected.", getWindow());
++        } else {
++          StringBuilder sb = new StringBuilder();
++          for (int i = 0; i < selectedRows.getLength(); i++) {
++            PositionAndOffset row = selectedRows.get(i);
++            sb.append(row.pos + "\t");
++            sb.append(row.offsets + "\t");
++            sb.append(row.payloadStr);
++            if (i < selectedRows.getLength() - 1) {
++              sb.append("\n");
++            }
++          }
++          LocalManifest content = new LocalManifest();
++          content.putText(sb.toString());
++          Clipboard.setContent(content);
++        }
++      }
++    });
++  }
++
++}
+Index: src/org/apache/lucene/luke/ui/TermVectorWindow.bxml
+===================================================================
+--- src/org/apache/lucene/luke/ui/TermVectorWindow.bxml	(revision 0)
++++ src/org/apache/lucene/luke/ui/TermVectorWindow.bxml	(working copy)
+@@ -0,0 +1,54 @@
++<?xml version="1.0" encoding="UTF-8"?>
++
++<luke:TermVectorWindow bxml:id="termVector" icon="/img/luke.gif"
++													title="%termVectorWindow_title" xmlns:bxml="http://pivot.apache.org/bxml"
++													xmlns:luke="org.apache.lucene.luke.ui" xmlns:content="org.apache.pivot.wtk.content"
++													xmlns="org.apache.pivot.wtk">
++	<content>
++		<TablePane styles="{verticalSpacing:10}">
++			<columns>
++				<TablePane.Column width="1*"/>
++			</columns>
++			<rows>
++				<TablePane.Row>
++					<BoxPane styles="{fill:true}">
++						<ImageView image="/img/info.gif"/>
++						<Label text="%termVectorWindow_field" />
++						<Label bxml:id="field" text="?" styles="{font:{bold:true}}"/>
++					</BoxPane>
++				</TablePane.Row>
++				<TablePane.Row>
++					<Border styles="{padding:1}">
++						<ScrollPane horizontalScrollBarPolicy="fill_to_capacity" styles="{backgroundColor:11}">
++							<view>
++								<TableView bxml:id="tvTable" selectMode="multi">
++									<columns>
++										<TableView.Column name="term"
++																			headerData="Term" width="100"/>
++										<TableView.Column name="freq"
++																			headerData="Freq." width="50"/>
++										<TableView.Column name="pos"
++																			headerData="Positions" width="100"/>
++										<TableView.Column name="offsets"
++																			headerData="Offsets" width="100" />
++									</columns>
++								</TableView>
++							</view>
++							<columnHeader>
++								<TableViewHeader tableView="$tvTable" sortMode="single_column" />
++							</columnHeader>
++						</ScrollPane>
++					</Border>
++				</TablePane.Row>
++				<TablePane.Row>
++					<BoxPane orientation="horizontal" styles="{horizontalAlignment:'right'}">
++						<PushButton buttonData="%label_ok"
++												ButtonPressListener.buttonPressed="termVector.close()">
++						</PushButton>
++						<PushButton bxml:id="tvCopyButton" buttonData="%label_clipboard"/>
++					</BoxPane>
++				</TablePane.Row>
++			</rows>
++		</TablePane>
++	</content>
++</luke:TermVectorWindow>
+\ No newline at end of file
+
+Property changes on: src/org/apache/lucene/luke/ui/TermVectorWindow.bxml
+___________________________________________________________________
+Added: svn:mime-type
+## -0,0 +1 ##
++text/xml
+\ No newline at end of property
+Index: src/org/apache/lucene/luke/ui/TermVectorWindow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/TermVectorWindow.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/TermVectorWindow.java	(working copy)
+@@ -0,0 +1,143 @@
++package org.apache.lucene.luke.ui;
++
++import org.apache.lucene.index.DocsAndPositionsEnum;
++import org.apache.lucene.index.DocsEnum;
++import org.apache.lucene.index.Terms;
++import org.apache.lucene.index.TermsEnum;
++import org.apache.lucene.luke.ui.util.TermVectorTableComparator;
++import org.apache.lucene.search.DocIdSetIterator;
++import org.apache.lucene.util.Bits;
++import org.apache.lucene.util.BytesRef;
++import org.apache.pivot.beans.BXML;
++import org.apache.pivot.beans.Bindable;
++import org.apache.pivot.collections.*;
++import org.apache.pivot.util.Resources;
++import org.apache.pivot.wtk.*;
++
++import java.io.IOException;
++import java.net.URL;
++
++public class TermVectorWindow extends Dialog implements Bindable{
++
++  @BXML
++  private Label field;
++  @BXML
++  private TableView tvTable;
++  @BXML
++  private PushButton tvCopyButton;
++
++  private Resources resources;
++
++  private List<Map<String, String>> tableData;
++
++  public static String TVROW_KEY_TERM = "term";
++  public static String TVROW_KEY_FREQ = "freq";
++  public static String TVROW_KEY_POSITION = "pos";
++  public static String TVROW_KEY_OFFSETS = "offsets";
++
++  @Override
++  public void initialize(Map<String, Object> map, URL url, Resources resources) {
++    this.resources = resources;
++  }
++
++  public void initTermVector(String fieldName, Terms tv) throws IOException {
++    field.setText(fieldName);
++    tableData = new ArrayList<Map<String, String>>();
++    TermsEnum te = tv.iterator(null);
++    BytesRef term = null;
++
++    // populate table data with term vector info
++    while((term = te.next()) != null) {
++      Map<String, String> row = new HashMap<String, String>();
++      tableData.add(row);
++      row.put(TVROW_KEY_TERM, term.utf8ToString());
++      // try to get DocsAndPositionsEnum
++      DocsEnum de = te.docsAndPositions(null, null);
++      if (de == null) {
++        // if positions are not indexed, get DocsEnum
++        de = te.docs(null, null);
++      }
++      // must have one doc
++      if (de.nextDoc() == DocIdSetIterator.NO_MORE_DOCS) {
++        continue;
++      }
++      row.put(TVROW_KEY_FREQ, String.valueOf(de.freq()));
++      if (de instanceof DocsAndPositionsEnum) {
++        // positions are available
++        DocsAndPositionsEnum dpe = (DocsAndPositionsEnum) de;
++        StringBuilder bufPos = new StringBuilder();
++        StringBuilder bufOff = new StringBuilder();
++        // enumerate all positions info
++        for (int i = 0; i < de.freq(); i++) {
++          int pos = dpe.nextPosition();
++          bufPos.append(String.valueOf(pos));
++          if (i < de.freq() - 1) {
++            bufPos.append((","));
++          }
++          // offsets are indexed?
++          int sOffset = dpe.startOffset();
++          int eOffset = dpe.endOffset();
++          if (sOffset >= 0 && eOffset >= 0) {
++            String offsets = String.valueOf(sOffset) + "-" + String.valueOf(eOffset);
++            bufOff.append(offsets);
++            if (i < de.freq() - 1) {
++              bufOff.append(",");
++            }
++          }
++        }
++        row.put(TVROW_KEY_POSITION, bufPos.toString());
++        row.put(TVROW_KEY_OFFSETS, (bufOff.length() == 0) ? "----" : bufOff.toString());
++      } else {
++        // positions are not available
++        row.put(TVROW_KEY_POSITION, "----");
++        row.put(TVROW_KEY_OFFSETS, "----");
++      }
++    }
++    // register sort listener
++    tvTable.getTableViewSortListeners().add(new TableViewSortListener.Adapter() {
++      @Override
++      public void sortChanged(TableView tableView) {
++        List<Map<String, String>> tableData = (List<Map<String, String>>) tableView.getTableData();
++        tableData.setComparator(new TermVectorTableComparator(tableView));
++      }
++    });
++    // default sort : by ascending order of term
++    Sequence<Dictionary.Pair<String, SortDirection>> sort = new ArrayList<Dictionary.Pair<String, SortDirection>>();
++    sort.add(new Dictionary.Pair<String, SortDirection>(TVROW_KEY_TERM, SortDirection.ASCENDING));
++    sort.add(new Dictionary.Pair<String, SortDirection>(TVROW_KEY_FREQ, SortDirection.DESCENDING));
++    tvTable.setSort(sort);
++
++    tvTable.setTableData(tableData);
++    addPushButtonListener();
++  }
++
++  private void addPushButtonListener() {
++
++    tvCopyButton.getButtonPressListeners().add(new ButtonPressListener() {
++      @Override
++      public void buttonPressed(Button button) {
++        // fired when 'Copy to Clipboard' button pressed
++        Sequence<Map<String, String>> selectedRows = (Sequence<Map<String, String>>) tvTable.getSelectedRows();
++        if (selectedRows == null || selectedRows.getLength() == 0) {
++          Alert.alert(MessageType.INFO, "No rows selected.", getWindow());
++        } else {
++          StringBuilder sb = new StringBuilder();
++          for (int i = 0; i < selectedRows.getLength(); i++) {
++            Map<String, String> row = selectedRows.get(i);
++            sb.append(row.get(TVROW_KEY_TERM) + "\t");
++            sb.append(row.get(TVROW_KEY_FREQ) + "\t");
++            sb.append(row.get(TVROW_KEY_POSITION) + "\t");
++            sb.append(row.get(TVROW_KEY_OFFSETS));
++            if (i < selectedRows.getLength() - 1) {
++              sb.append("\n");
++            }
++          }
++          LocalManifest content = new LocalManifest();
++          content.putText(sb.toString());
++          Clipboard.setContent(content);
++        }
++      }
++    });
++  }
++
++}
+Index: src/org/apache/lucene/luke/ui/util/FieldsTableRow.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/util/FieldsTableRow.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/util/FieldsTableRow.java	(working copy)
+@@ -0,0 +1,61 @@
++package org.apache.lucene.luke.ui.util;
++
++/*
++ * 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.
++ */
++
++import org.apache.lucene.luke.core.decoders.Decoder;
++import org.apache.lucene.luke.ui.LukeWindow;
++
++public class FieldsTableRow {
++  private String name;
++  private String termCount;
++  private String percent;
++  private Decoder decoder;
++
++  private LukeWindow.LukeMediator lukeMediator;
++
++  public FieldsTableRow(LukeWindow.LukeMediator lukeMediator) {
++    this.lukeMediator = lukeMediator;
++  }
++
++  public String getName() {
++    return name;
++  }
++  public void setName(String name) {
++    this.name = name;
++  }
++  public String getTermCount() {
++    return termCount;
++  }
++  public void setTermCount(String termCount) {
++    this.termCount = termCount;
++  }
++  public String getPercent() {
++    return percent;
++  }
++  public void setPercent(String percent) {
++    this.percent = percent;
++  }
++  public Decoder getDecoder() {
++    return decoder;
++  }
++  public void setDecoder(Decoder decoder) {
++    this.decoder = decoder;
++    this.lukeMediator.getDecoders().put(name, decoder);
++  }
++
++}
+Index: src/org/apache/lucene/luke/ui/util/TableComparator.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/util/TableComparator.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/util/TableComparator.java	(working copy)
+@@ -0,0 +1,102 @@
++package org.apache.lucene.luke.ui.util;
++
++/*
++ * 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.
++ */
++
++
++import java.util.Comparator;
++
++import org.apache.pivot.collections.Dictionary;
++import org.apache.pivot.wtk.SortDirection;
++import org.apache.pivot.wtk.TableView;
++
++public class TableComparator implements Comparator<FieldsTableRow> {
++  private TableView tableView;
++
++  public TableComparator(TableView fieldsTable) {
++    if (fieldsTable == null) {
++      throw new IllegalArgumentException();
++    }
++
++    this.tableView = fieldsTable;
++  }
++
++  @Override
++  public int compare(FieldsTableRow row1, FieldsTableRow row2) {
++    Dictionary.Pair<String, SortDirection> sort = tableView.getSort().get(0);
++
++    int result;
++    if (sort.key.equals("name")) {
++      // sort by name
++      result = row1.getName().compareTo(row2.getName());
++    } else if (sort.key.equals("termCount")) {
++      // sort by termCount
++      Integer c1 = Integer.parseInt(row1.getTermCount());
++      Integer c2 = Integer.parseInt(row2.getTermCount());
++      result = c1.compareTo(c2);
++    } else {
++      // other (ignored)
++      result = 0;
++    }
++    //int result = o1.get("name").compareTo(o2.get("name"));
++    //SortDirection sortDirection = tableView.getSort().get("name");
++    SortDirection sortDirection = sort.value;
++    result *= (sortDirection == SortDirection.DESCENDING ? 1 : -1);
++
++    return result * -1;
++  }
++}
++
++/*
++public class TableComparator implements Comparator<Map<String,String>> {
++  private TableView tableView;
++  
++  public TableComparator(TableView fieldsTable) {
++    if (fieldsTable == null) {
++      throw new IllegalArgumentException();
++    }
++    
++    this.tableView = fieldsTable;
++  }
++  
++  @Override
++  public int compare(Map<String,String> o1, Map<String,String> o2) {
++    Dictionary.Pair<String, SortDirection> sort = tableView.getSort().get(0);
++
++    int result;
++    if (sort.key.equals("name")) {
++      // sort by name
++      result = o1.get(sort.key).compareTo(o2.get(sort.key));
++    } else if (sort.key.equals("termCount")) {
++      // sort by termCount
++      Integer c1 = Integer.parseInt(o1.get(sort.key));
++      Integer c2 = Integer.parseInt(o2.get(sort.key));
++      result = c1.compareTo(c2);
++    } else {
++      // other (ignored)
++      result = 0;
++    }
++    //int result = o1.get("name").compareTo(o2.get("name"));
++    //SortDirection sortDirection = tableView.getSort().get("name");
++    SortDirection sortDirection = sort.value;
++    result *= (sortDirection == SortDirection.DESCENDING ? 1 : -1);
++
++    return result * -1;
++  }
++  
++}
++*/
+Index: src/org/apache/lucene/luke/ui/util/TermVectorTableComparator.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/util/TermVectorTableComparator.java	(revision 0)
++++ src/org/apache/lucene/luke/ui/util/TermVectorTableComparator.java	(working copy)
+@@ -0,0 +1,46 @@
++package org.apache.lucene.luke.ui.util;
++
++import org.apache.pivot.collections.Dictionary;
++import org.apache.pivot.collections.Map;
++import org.apache.pivot.wtk.SortDirection;
++import org.apache.pivot.wtk.TableView;
++
++import java.util.Comparator;
++
++import static org.apache.lucene.luke.ui.TermVectorWindow.TVROW_KEY_FREQ;
++import static org.apache.lucene.luke.ui.TermVectorWindow.TVROW_KEY_TERM;
++
++
++public class TermVectorTableComparator implements Comparator<Map<String, String>> {
++  private TableView tableView;
++
++  public TermVectorTableComparator(TableView tableView) {
++    if (tableView == null) {
++      throw new IllegalArgumentException();
++    }
++    this.tableView = tableView;
++  }
++
++  @Override
++  public int compare(Map<String, String> row1, Map<String, String> row2) {
++    Dictionary.Pair<String, SortDirection> sort = tableView.getSort().get(0);
++
++    int result;
++    if (sort.key.equals(TVROW_KEY_TERM)) {
++      // sort by name
++      result = row1.get(TVROW_KEY_TERM).compareTo(row2.get(TVROW_KEY_TERM));
++    } else if (sort.key.equals(TVROW_KEY_FREQ)) {
++      // sort by termCount
++      Integer f1 = Integer.parseInt(row1.get(TVROW_KEY_FREQ));
++      Integer f2 = Integer.parseInt(row2.get(TVROW_KEY_FREQ));
++      result = f1.compareTo(f2);
++    } else {
++      // other (ignored)
++      result = 0;
++    }
++    SortDirection sortDirection = sort.value;
++    result *= (sortDirection == SortDirection.DESCENDING ? 1 : -1);
++
++    return result * -1;
++  }
++}
diff --git a/attachments/LUCENE-2562/LUCENE-2562-ivy.patch b/attachments/LUCENE-2562/LUCENE-2562-ivy.patch
new file mode 100644
index 0000000..80382ac
--- /dev/null
+++ b/attachments/LUCENE-2562/LUCENE-2562-ivy.patch
@@ -0,0 +1,252 @@
+Index: build.xml
+===================================================================
+--- build.xml	(revision 1652561)
++++ build.xml	(working copy)
+@@ -1,8 +1,9 @@
+-<project name="Luke" default="dist">
++<project name="Luke" default="dist" xmlns:ivy="antlib:org.apache.ivy.ant">
+   <defaultexcludes add="**/CVS" />
+   <property name="build.dir" value="build" />
+-  <property name="build.ver" value="4.3.1" />
++  <property name="build.ver" value="4.10.3" />
+   <property name="dist.dir" value="dist" />
++  <property name="ivy.lib.dir" value="lib-ivy" />
+   <property name="jarfile" value="${build.dir}/luke-${build.ver}.jar" />
+   <property name="jarallfile" value="${build.dir}/lukeall-${build.ver}.jar" />
+   <property name="jarminfile" value="${build.dir}/lukemin-${build.ver}.jar" />
+@@ -19,10 +20,26 @@
+     <delete dir="${dist.dir}" />
+   </target>
+ 
++  <!-- resolve dependencies -->
++  <path id="ivy.lib.path">
++    <fileset dir="lib/tools" includes="*.jar"/>
++  </path>
++  <taskdef resource="org/apache/ivy/ant/antlib.xml"
++           uri="antlib:org.apache.ivy.ant" classpathref="ivy.lib.path"/>
++  <target name="ivy-resolve">
++    <ivy:retrieve conf="lucene" pattern="${ivy.lib.dir}/[artifact].[ext]"/>
++    <ivy:retrieve conf="pivot" pattern="${ivy.lib.dir}/[artifact].[ext]"/>
++    <ivy:retrieve conf="solr" pattern="${ivy.lib.dir}/[conf]/[artifact].[ext]"/>
++    <ivy:retrieve conf="hadoop" pattern="${ivy.lib.dir}/[conf]/[artifact].[ext]"/>
++  </target>
++  <target name="ivy-clean">
++    <delete dir="${ivy.lib.dir}"/>
++  </target>
++
+   <target name="compile" depends="init">
+     <javac classpath="${classpath}" sourcepath="" source="1.5" target="1.5" srcdir="src" destdir="${build.dir}">
+       <classpath>
+-        <fileset dir="lib">
++        <fileset dir="${ivy.lib.dir}">
+           <include name="**/*.jar" />
+         </fileset>
+       </classpath>
+@@ -33,7 +50,7 @@
+   <target name="javadoc" depends="init">
+     <javadoc sourcepath="src" packagenames="org.*" destdir="${build.dir}/api">
+       <classpath>
+-        <fileset dir="lib">
++        <fileset dir="${ivy.lib.dir}">
+           <include name="**/*.jar" />
+         </fileset>
+       </classpath>
+@@ -53,7 +70,7 @@
+       </manifest>
+     </jar>
+     <unjar dest="${build.dir}">
+-      <fileset dir="lib" includes="lucene-*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="lucene-*.jar" />
+     </unjar>
+     <jar basedir="${build.dir}" jarfile="${jarminfile}" includes=".plugins,img/,org/" excludes="org/mozilla/,org/apache/lucene/luke/plugins,**/*.js">
+       <manifest>
+@@ -62,20 +79,20 @@
+       </manifest>
+     </jar>
+     <unjar dest="${build.dir}">
+-      <fileset dir="lib" includes="pivot*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="pivot*.jar" />
+     </unjar>
+     <unjar dest="${build.dir}">
+       <fileset dir="lib" includes="js.jar" />
+-      <fileset dir="lib" includes="lucene*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="lucene*.jar" />
+     </unjar>
+     <unjar dest="${build.dir}">
+-      <fileset dir="lib" includes="hadoop/*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="hadoop/*.jar" />
+     </unjar>
+     <unjar dest="${build.dir}">
+-      <fileset dir="lib" includes="solr/*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="solr/*.jar" />
+     </unjar>
+     <unjar dest="${build.dir}">
+-      <fileset dir="lib" includes="lucene-core-*.jar" />
++      <fileset dir="${ivy.lib.dir}" includes="lucene-core-*.jar" />
+       <patternset>
+         <include name="META-INF/MANIFEST.MF" />
+       </patternset>
+@@ -99,7 +116,8 @@
+       </patternset>
+     </fileset>
+     <copy todir="${dist.dir}">
+-      <fileset dir="lib" />
++      <fileset dir="lib" includes="js.jar"/>
++      <fileset dir="${ivy.lib.dir}" />
+       <fileset file="${jarfile}" />
+       <fileset file="${jarallfile}" />
+       <fileset file="${jarminfile}" />
+Index: ivy.xml
+===================================================================
+--- ivy.xml	(revision 0)
++++ ivy.xml	(working copy)
+@@ -0,0 +1,57 @@
++<ivy-module version="2.0">
++  <info organisation="org.apache.lucene" module="luke"/>
++  <configurations>
++    <conf name="lucene" description="for Lucene jars"/>
++    <conf name="pivot" description="for Pivot jars"/>
++    <conf name="solr" description="for Solr jars"/>
++    <conf name="hadoop" description="for Hadoop jars"/>
++  </configurations>
++  <dependencies>
++    <!-- apache lucene -->
++    <dependency org="org.apache.lucene" name="lucene-analyzers-common" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++    <dependency org="org.apache.lucene" name="lucene-codecs" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++    <dependency org="org.apache.lucene" name="lucene-core" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++    <dependency org="org.apache.lucene" name="lucene-misc" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++    <dependency org="org.apache.lucene" name="lucene-queries" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++    <dependency org="org.apache.lucene" name="lucene-queryparser" rev="4.10.3"
++                conf="lucene->*,!sources,!javadoc"/>
++
++    <!-- apache pivot -->
++    <dependency org="org.apache.pivot" name="pivot-charts" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++    <dependency org="org.apache.pivot" name="pivot-core" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++    <dependency org="org.apache.pivot" name="pivot-web" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++    <dependency org="org.apache.pivot" name="pivot-web-server" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++    <dependency org="org.apache.pivot" name="pivot-wtk" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++    <dependency org="org.apache.pivot" name="pivot-wtk-terra" rev="2.0.4"
++                conf="pivot->*,!sources,!javadoc"/>
++
++    <!-- apache solr -->
++    <dependency org="org.apache.solr" name="solr-core" rev="4.10.3"
++                transitive="false"
++                conf="solr->*,!sources,!javadoc"/>
++    <dependency org="org.apache.solr" name="solr-solrj" rev="4.10.3"
++                transitive="false"
++                conf="solr->*,!sources,!javadoc"/>
++
++    <!-- apache hadoop -->
++    <dependency org="org.apache.hadoop" name="hadoop-core" rev="0.20.2"
++                conf="hadoop->*,!sources,!javadoc"/>
++      <dependency org="org.slf4j" name="slf4j-api" rev="1.4.3"
++                  conf="hadoop->*,!sources,!javadoc"/>
++      <dependency org="org.slf4j" name="slf4j-log4j12" rev="1.4.3"
++                  conf="hadoop->*,!sources,!javadoc"/>
++      <dependency org="net.sf.ehcache" name="ehcache" rev="1.6.0"
++                  conf="hadoop->*,!sources,!javadoc"/>
++
++  </dependencies>
++</ivy-module>
+\ No newline at end of file
+Index: lib/tools/ivy-2.3.0.jar
+===================================================================
+Cannot display: file marked as a binary type.
+svn:mime-type = application/jar
+Index: lib/tools/ivy-2.3.0.jar
+===================================================================
+--- lib/tools/ivy-2.3.0.jar	(revision 0)
++++ lib/tools/ivy-2.3.0.jar	(working copy)
+
+Property changes on: lib/tools/ivy-2.3.0.jar
+___________________________________________________________________
+Added: svn:mime-type
+## -0,0 +1 ##
++application/jar
+\ No newline at end of property
+Index: src/org/apache/lucene/index/IndexGate.java
+===================================================================
+--- src/org/apache/lucene/index/IndexGate.java	(revision 1652561)
++++ src/org/apache/lucene/index/IndexGate.java	(working copy)
+@@ -146,7 +146,8 @@
+     infos.read(dir);
+     int compound = 0, nonCompound = 0;
+     for (int i = 0; i < infos.size(); i++) {
+-      if (((SegmentInfoPerCommit)infos.info(i)).info.getUseCompoundFile()) {
++      //if (((SegmentInfoPerCommit)infos.info(i)).info.getUseCompoundFile()) {
++      if (infos.info(i).info.getUseCompoundFile()) {
+         compound++;
+       } else {
+         nonCompound++;
+Index: src/org/apache/lucene/luke/core/IndexInfo.java
+===================================================================
+--- src/org/apache/lucene/luke/core/IndexInfo.java	(revision 1652561)
++++ src/org/apache/lucene/luke/core/IndexInfo.java	(working copy)
+@@ -74,7 +74,8 @@
+     
+     AtomicReader r;
+     if (reader instanceof CompositeReader) {
+-      r = new SlowCompositeReaderWrapper((CompositeReader)reader);
++      //r = new SlowCompositeReaderWrapper((CompositeReader)reader);
++      r = SlowCompositeReaderWrapper.wrap(reader);
+     } else {
+       r = (AtomicReader)reader;
+     }
+Index: src/org/apache/lucene/luke/ui/AnalyzersTab.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/AnalyzersTab.java	(revision 1652561)
++++ src/org/apache/lucene/luke/ui/AnalyzersTab.java	(working copy)
+@@ -68,7 +68,15 @@
+         .getAnalyzerNames()));
+     analyzersListButton.setSelectedIndex(0);
+     List<String> versions = new ArrayList<String>();
+-    Version[] values = Version.values();
++    // TODO: Version.values() was removed, and Version.LUCENE_X_X_X were all depricated. How do we fix this line?
++    //Version[] values = Version.values();
++    Version[] values = {
++      Version.LUCENE_3_0_0, Version.LUCENE_3_1_0, Version.LUCENE_3_2_0, Version.LUCENE_3_3_0,
++      Version.LUCENE_3_4_0, Version.LUCENE_3_5_0, Version.LUCENE_3_6_0,
++      Version.LUCENE_4_1_0, Version.LUCENE_4_2_0, Version.LUCENE_4_3_0, Version.LUCENE_4_4_0,
++      Version.LUCENE_4_5_0, Version.LUCENE_4_6_0, Version.LUCENE_4_7_0, Version.LUCENE_4_8_0,
++      Version.LUCENE_4_9_0, Version.LUCENE_4_10_0
++    };
+     for (int i = 0; i < values.length; i++) {
+       Version v = values[i];
+       versions.add(v.toString());
+@@ -116,8 +124,10 @@
+   
+   public void analyze() {
+     try {
+-      Version v = Version.valueOf((String) luceneVersionListButton
+-          .getSelectedItem());
++      //Version v = Version.valueOf((String) luceneVersionListButton
++      // .getSelectedItem());
++      Version v = Version.parseLeniently((String) luceneVersionListButton
++        .getSelectedItem());
+       Class clazz = Class.forName((String) analyzersListButton
+           .getSelectedItem());
+       Analyzer analyzer = null;
+Index: src/org/apache/lucene/luke/ui/DocumentsTab.java
+===================================================================
+--- src/org/apache/lucene/luke/ui/DocumentsTab.java	(revision 1652561)
++++ src/org/apache/lucene/luke/ui/DocumentsTab.java	(working copy)
+@@ -192,7 +192,8 @@
+     this.idxInfo = lukeMediator.getIndexInfo();
+     this.ir = idxInfo.getReader();
+     if (ir instanceof CompositeReader) {
+-      ar = new SlowCompositeReaderWrapper((CompositeReader) ir);
++      //ar = new SlowCompositeReaderWrapper((CompositeReader) ir);
++      ar = SlowCompositeReaderWrapper.wrap(ir);
+     } else if (ir instanceof AtomicReader) {
+       ar = (AtomicReader) ir;
+     }
diff --git a/attachments/LUCENE-2562/LUCENE-2562.patch b/attachments/LUCENE-2562/LUCENE-2562.patch
new file mode 100644
index 0000000..e3498db
--- /dev/null
+++ b/attachments/LUCENE-2562/LUCENE-2562.patch
@@ -0,0 +1,24845 @@
+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:
diff --git a/attachments/LUCENE-2562/Luke-ALE-1.png b/attachments/LUCENE-2562/Luke-ALE-1.png
new file mode 100644
index 0000000..29a6b8d
--- /dev/null
+++ b/attachments/LUCENE-2562/Luke-ALE-1.png
Binary files differ
diff --git a/attachments/LUCENE-2562/Luke-ALE-2.png b/attachments/LUCENE-2562/Luke-ALE-2.png
new file mode 100644
index 0000000..fc55d80
--- /dev/null
+++ b/attachments/LUCENE-2562/Luke-ALE-2.png
Binary files differ
diff --git a/attachments/LUCENE-2562/Luke-ALE-3.png b/attachments/LUCENE-2562/Luke-ALE-3.png
new file mode 100644
index 0000000..c4d1a75
--- /dev/null
+++ b/attachments/LUCENE-2562/Luke-ALE-3.png
Binary files differ
diff --git a/attachments/LUCENE-2562/Luke-ALE-4.png b/attachments/LUCENE-2562/Luke-ALE-4.png
new file mode 100644
index 0000000..c7db7dd
--- /dev/null
+++ b/attachments/LUCENE-2562/Luke-ALE-4.png
Binary files differ
diff --git a/attachments/LUCENE-2562/Luke-ALE-5.png b/attachments/LUCENE-2562/Luke-ALE-5.png
new file mode 100644
index 0000000..f2bbdd7
--- /dev/null
+++ b/attachments/LUCENE-2562/Luke-ALE-5.png
Binary files differ
diff --git a/attachments/LUCENE-2562/luke-javafx1.png b/attachments/LUCENE-2562/luke-javafx1.png
new file mode 100644
index 0000000..fed9d0d
--- /dev/null
+++ b/attachments/LUCENE-2562/luke-javafx1.png
Binary files differ
diff --git a/attachments/LUCENE-2562/luke-javafx2.png b/attachments/LUCENE-2562/luke-javafx2.png
new file mode 100644
index 0000000..ed4a2bc
--- /dev/null
+++ b/attachments/LUCENE-2562/luke-javafx2.png
Binary files differ
diff --git a/attachments/LUCENE-2562/luke-javafx3.png b/attachments/LUCENE-2562/luke-javafx3.png
new file mode 100644
index 0000000..590a389
--- /dev/null
+++ b/attachments/LUCENE-2562/luke-javafx3.png
Binary files differ
diff --git a/attachments/LUCENE-2562/luke1.jpg b/attachments/LUCENE-2562/luke1.jpg
new file mode 100644
index 0000000..6f0f9a8
--- /dev/null
+++ b/attachments/LUCENE-2562/luke1.jpg
Binary files differ
diff --git a/attachments/LUCENE-2562/luke2.jpg b/attachments/LUCENE-2562/luke2.jpg
new file mode 100644
index 0000000..d8f0a2f
--- /dev/null
+++ b/attachments/LUCENE-2562/luke2.jpg
Binary files differ
diff --git a/attachments/LUCENE-2562/luke3.jpg b/attachments/LUCENE-2562/luke3.jpg
new file mode 100644
index 0000000..197fe7d
--- /dev/null
+++ b/attachments/LUCENE-2562/luke3.jpg
Binary files differ
diff --git a/attachments/LUCENE-2562/lukeALE-documents.png b/attachments/LUCENE-2562/lukeALE-documents.png
new file mode 100644
index 0000000..54b1d97
--- /dev/null
+++ b/attachments/LUCENE-2562/lukeALE-documents.png
Binary files differ
diff --git a/attachments/LUCENE-2562/screenshot-1.png b/attachments/LUCENE-2562/screenshot-1.png
new file mode 100644
index 0000000..cd5612c
--- /dev/null
+++ b/attachments/LUCENE-2562/screenshot-1.png
Binary files differ
diff --git "a/attachments/LUCENE-2562/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2018-11-05 9.19.47.png" "b/attachments/LUCENE-2562/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2018-11-05 9.19.47.png"
new file mode 100644
index 0000000..81a141f
--- /dev/null
+++ "b/attachments/LUCENE-2562/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2018-11-05 9.19.47.png"
Binary files differ
diff --git a/attachments/LUCENE-4051/LUCENE-4051.patch b/attachments/LUCENE-4051/LUCENE-4051.patch
new file mode 100644
index 0000000..cde000b
--- /dev/null
+++ b/attachments/LUCENE-4051/LUCENE-4051.patch
@@ -0,0 +1,814 @@
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsFormat.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsFormat.java
+index 240d16d..32b03e5 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsFormat.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsFormat.java
+@@ -28,6 +28,7 @@ import org.apache.lucene.index.SegmentInfo;
+ import org.apache.lucene.store.DataOutput; // javadocs
+ import org.apache.lucene.store.Directory;
+ import org.apache.lucene.store.IOContext;
++import org.apache.lucene.util.CodecUtil;
+ 
+ /** 
+  * Lucene 4.0 Stored Fields Format.
+@@ -42,7 +43,8 @@ import org.apache.lucene.store.IOContext;
+  * <p>This contains, for each document, a pointer to its field data, as
+  * follows:</p>
+  * <ul>
+- * <li>FieldIndex (.fdx) --&gt; &lt;FieldValuesPosition&gt; <sup>SegSize</sup></li>
++ * <li>FieldIndex (.fdx) --&gt; &lt;Header&gt;, &lt;FieldValuesPosition&gt; <sup>SegSize</sup></li>
++ * <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}</li>
+  * <li>FieldValuesPosition --&gt; {@link DataOutput#writeLong Uint64}</li>
+  * </ul>
+  * </li>
+@@ -50,7 +52,8 @@ import org.apache.lucene.store.IOContext;
+  * <p><a name="field_data" id="field_data"></a>The field data, or <tt>.fdt</tt> file.</p>
+  * <p>This contains the stored fields of each document, as follows:</p>
+  * <ul>
+- * <li>FieldData (.fdt) --&gt; &lt;DocFieldData&gt; <sup>SegSize</sup></li>
++ * <li>FieldData (.fdt) --&gt; &lt;Header&gt;, &lt;DocFieldData&gt; <sup>SegSize</sup></li>
++ * <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}</li>
+  * <li>DocFieldData --&gt; FieldCount, &lt;FieldNum, Bits, Value&gt;
+  * <sup>FieldCount</sup></li>
+  * <li>FieldCount --&gt; {@link DataOutput#writeVInt VInt}</li>
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsReader.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsReader.java
+index ab89821..99cfe4f 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsReader.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsReader.java
+@@ -30,11 +30,14 @@ import org.apache.lucene.store.AlreadyClosedException;
+ import org.apache.lucene.store.Directory;
+ import org.apache.lucene.store.IOContext;
+ import org.apache.lucene.store.IndexInput;
++import org.apache.lucene.util.CodecUtil;
+ import org.apache.lucene.util.IOUtils;
+ 
+ import java.io.Closeable;
+ import java.util.Set;
+ 
++import static org.apache.lucene.codecs.lucene40.Lucene40StoredFieldsWriter.*;
++
+ /**
+  * Class responsible for access to stored document fields.
+  * <p/>
+@@ -44,8 +47,6 @@ import java.util.Set;
+  * @lucene.internal
+  */
+ public final class Lucene40StoredFieldsReader extends StoredFieldsReader implements Cloneable, Closeable {
+-  private final static int FORMAT_SIZE = 4;
+-
+   private final FieldInfos fieldInfos;
+   private final IndexInput fieldsStream;
+   private final IndexInput indexStream;
+@@ -78,17 +79,15 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+     boolean success = false;
+     fieldInfos = fn;
+     try {
+-      fieldsStream = d.openInput(IndexFileNames.segmentFileName(segment, "", Lucene40StoredFieldsWriter.FIELDS_EXTENSION), context);
+-      final String indexStreamFN = IndexFileNames.segmentFileName(segment, "", Lucene40StoredFieldsWriter.FIELDS_INDEX_EXTENSION);
++      fieldsStream = d.openInput(IndexFileNames.segmentFileName(segment, "", FIELDS_EXTENSION), context);
++      final String indexStreamFN = IndexFileNames.segmentFileName(segment, "", FIELDS_INDEX_EXTENSION);
+       indexStream = d.openInput(indexStreamFN, context);
+       
+-      // its a 4.0 codec: so its not too-old, its corrupt.
+-      // TODO: change this to CodecUtil.checkHeader
+-      if (Lucene40StoredFieldsWriter.FORMAT_CURRENT != indexStream.readInt()) {
+-        throw new CorruptIndexException("unexpected fdx header: " + indexStream);
+-      }
+-
+-      final long indexSize = indexStream.length() - FORMAT_SIZE;
++      CodecUtil.checkHeader(indexStream, CODEC_NAME_IDX, VERSION_START, VERSION_CURRENT);
++      CodecUtil.checkHeader(fieldsStream, CODEC_NAME_DAT, VERSION_START, VERSION_CURRENT);
++      assert HEADER_LENGTH_DAT == fieldsStream.getFilePointer();
++      assert HEADER_LENGTH_IDX == indexStream.getFilePointer();
++      final long indexSize = indexStream.length() - HEADER_LENGTH_IDX;
+       this.size = (int) (indexSize >> 3);
+       // Verify two sources of "maxDoc" agree:
+       if (this.size != si.docCount) {
+@@ -135,7 +134,7 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+   }
+ 
+   private void seekIndex(int docID) throws IOException {
+-    indexStream.seek(FORMAT_SIZE + docID * 8L);
++    indexStream.seek(HEADER_LENGTH_IDX + docID * 8L);
+   }
+ 
+   public final void visitDocument(int n, StoredFieldVisitor visitor) throws CorruptIndexException, IOException {
+@@ -148,7 +147,7 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+       FieldInfo fieldInfo = fieldInfos.fieldInfo(fieldNumber);
+       
+       int bits = fieldsStream.readByte() & 0xFF;
+-      assert bits <= (Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_MASK | Lucene40StoredFieldsWriter.FIELD_IS_BINARY): "bits=" + Integer.toHexString(bits);
++      assert bits <= (FIELD_IS_NUMERIC_MASK | FIELD_IS_BINARY): "bits=" + Integer.toHexString(bits);
+ 
+       switch(visitor.needsField(fieldInfo)) {
+         case YES:
+@@ -164,19 +163,19 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+   }
+ 
+   private void readField(StoredFieldVisitor visitor, FieldInfo info, int bits) throws IOException {
+-    final int numeric = bits & Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_MASK;
++    final int numeric = bits & FIELD_IS_NUMERIC_MASK;
+     if (numeric != 0) {
+       switch(numeric) {
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_INT:
++        case FIELD_IS_NUMERIC_INT:
+           visitor.intField(info, fieldsStream.readInt());
+           return;
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_LONG:
++        case FIELD_IS_NUMERIC_LONG:
+           visitor.longField(info, fieldsStream.readLong());
+           return;
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_FLOAT:
++        case FIELD_IS_NUMERIC_FLOAT:
+           visitor.floatField(info, Float.intBitsToFloat(fieldsStream.readInt()));
+           return;
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_DOUBLE:
++        case FIELD_IS_NUMERIC_DOUBLE:
+           visitor.doubleField(info, Double.longBitsToDouble(fieldsStream.readLong()));
+           return;
+         default:
+@@ -186,7 +185,7 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+       final int length = fieldsStream.readVInt();
+       byte bytes[] = new byte[length];
+       fieldsStream.readBytes(bytes, 0, length);
+-      if ((bits & Lucene40StoredFieldsWriter.FIELD_IS_BINARY) != 0) {
++      if ((bits & FIELD_IS_BINARY) != 0) {
+         visitor.binaryField(info, bytes, 0, bytes.length);
+       } else {
+         visitor.stringField(info, new String(bytes, 0, bytes.length, IOUtils.CHARSET_UTF_8));
+@@ -195,15 +194,15 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+   }
+   
+   private void skipField(int bits) throws IOException {
+-    final int numeric = bits & Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_MASK;
++    final int numeric = bits & FIELD_IS_NUMERIC_MASK;
+     if (numeric != 0) {
+       switch(numeric) {
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_INT:
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_FLOAT:
++        case FIELD_IS_NUMERIC_INT:
++        case FIELD_IS_NUMERIC_FLOAT:
+           fieldsStream.readInt();
+           return;
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_LONG:
+-        case Lucene40StoredFieldsWriter.FIELD_IS_NUMERIC_DOUBLE:
++        case FIELD_IS_NUMERIC_LONG:
++        case FIELD_IS_NUMERIC_DOUBLE:
+           fieldsStream.readLong();
+           return;
+         default: 
+@@ -242,7 +241,7 @@ public final class Lucene40StoredFieldsReader extends StoredFieldsReader impleme
+   }
+ 
+   public static void files(SegmentInfo info, Set<String> files) throws IOException {
+-    files.add(IndexFileNames.segmentFileName(info.name, "", Lucene40StoredFieldsWriter.FIELDS_INDEX_EXTENSION));
+-    files.add(IndexFileNames.segmentFileName(info.name, "", Lucene40StoredFieldsWriter.FIELDS_EXTENSION));
++    files.add(IndexFileNames.segmentFileName(info.name, "", FIELDS_INDEX_EXTENSION));
++    files.add(IndexFileNames.segmentFileName(info.name, "", FIELDS_EXTENSION));
+   }
+ }
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsWriter.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsWriter.java
+index c236d9c..15f2ea5 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsWriter.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40StoredFieldsWriter.java
+@@ -34,6 +34,7 @@ import org.apache.lucene.store.IndexInput;
+ import org.apache.lucene.store.IndexOutput;
+ import org.apache.lucene.util.Bits;
+ import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.CodecUtil;
+ import org.apache.lucene.util.IOUtils;
+ 
+ /** 
+@@ -62,16 +63,14 @@ public final class Lucene40StoredFieldsWriter extends StoredFieldsWriter {
+   // currently unused: static final int FIELD_IS_NUMERIC_SHORT = 5 << _NUMERIC_BIT_SHIFT;
+   // currently unused: static final int FIELD_IS_NUMERIC_BYTE = 6 << _NUMERIC_BIT_SHIFT;
+ 
+-  // (Happens to be the same as for now) Lucene 3.2: NumericFields are stored in binary format
+-  static final int FORMAT_LUCENE_3_2_NUMERIC_FIELDS = 3;
++  static final String CODEC_NAME_IDX = "Lucene40StoredFieldsIndex";
++  static final String CODEC_NAME_DAT = "Lucene40StoredFieldsData";
++  static final int VERSION_START = 0;
++  static final int VERSION_CURRENT = VERSION_START;
++  static final long HEADER_LENGTH_IDX = CodecUtil.headerLength(CODEC_NAME_IDX);
++  static final long HEADER_LENGTH_DAT = CodecUtil.headerLength(CODEC_NAME_DAT);
+ 
+-  // NOTE: if you introduce a new format, make it 1 higher
+-  // than the current one, and always change this if you
+-  // switch to a new format!
+-  static final int FORMAT_CURRENT = FORMAT_LUCENE_3_2_NUMERIC_FIELDS;
+ 
+-  // when removing support for old versions, leave the last supported version here
+-  static final int FORMAT_MINIMUM = FORMAT_LUCENE_3_2_NUMERIC_FIELDS;
+ 
+   /** Extension of stored fields file */
+   public static final String FIELDS_EXTENSION = "fdt";
+@@ -94,9 +93,10 @@ public final class Lucene40StoredFieldsWriter extends StoredFieldsWriter {
+       fieldsStream = directory.createOutput(IndexFileNames.segmentFileName(segment, "", FIELDS_EXTENSION), context);
+       indexStream = directory.createOutput(IndexFileNames.segmentFileName(segment, "", FIELDS_INDEX_EXTENSION), context);
+ 
+-      fieldsStream.writeInt(FORMAT_CURRENT);
+-      indexStream.writeInt(FORMAT_CURRENT);
+-
++      CodecUtil.writeHeader(fieldsStream, CODEC_NAME_DAT, VERSION_CURRENT);
++      CodecUtil.writeHeader(indexStream, CODEC_NAME_IDX, VERSION_CURRENT);
++      assert HEADER_LENGTH_DAT == fieldsStream.getFilePointer();
++      assert HEADER_LENGTH_IDX == indexStream.getFilePointer();
+       success = true;
+     } finally {
+       if (!success) {
+@@ -209,7 +209,7 @@ public final class Lucene40StoredFieldsWriter extends StoredFieldsWriter {
+ 
+   @Override
+   public void finish(int numDocs) throws IOException {
+-    if (4+((long) numDocs)*8 != indexStream.getFilePointer())
++    if (HEADER_LENGTH_IDX+((long) numDocs)*8 != indexStream.getFilePointer())
+       // This is most likely a bug in Sun JRE 1.6.0_04/_05;
+       // we detect that the bug has struck, here, and
+       // throw an exception to prevent the corruption from
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsFormat.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsFormat.java
+index b7fc812..7f39676 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsFormat.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsFormat.java
+@@ -28,6 +28,7 @@ import org.apache.lucene.index.SegmentInfo;
+ import org.apache.lucene.store.DataOutput; // javadocs
+ import org.apache.lucene.store.Directory;
+ import org.apache.lucene.store.IOContext;
++import org.apache.lucene.util.CodecUtil;
+ 
+ /**
+  * Lucene 4.0 Term Vectors format.
+@@ -38,10 +39,10 @@ import org.apache.lucene.store.IOContext;
+  * <p>The Document Index or .tvx file.</p>
+  * <p>For each document, this stores the offset into the document data (.tvd) and
+  * field data (.tvf) files.</p>
+- * <p>DocumentIndex (.tvx) --&gt; TVXVersion&lt;DocumentPosition,FieldPosition&gt;
++ * <p>DocumentIndex (.tvx) --&gt; Header,&lt;DocumentPosition,FieldPosition&gt;
+  * <sup>NumDocs</sup></p>
+  * <ul>
+- *   <li>TVXVersion --&gt; {@link DataOutput#writeInt Int32} (<code>Lucene40TermVectorsReader.FORMAT_CURRENT</code>)</li>
++ *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}</li>
+  *   <li>DocumentPosition --&gt; {@link DataOutput#writeLong UInt64} (offset in the .tvd file)</li>
+  *   <li>FieldPosition --&gt; {@link DataOutput#writeLong UInt64} (offset in the .tvf file)</li>
+  * </ul>
+@@ -53,10 +54,10 @@ import org.apache.lucene.store.IOContext;
+  * in the .tvf (Term Vector Fields) file.</p>
+  * <p>The .tvd file is used to map out the fields that have term vectors stored
+  * and where the field information is in the .tvf file.</p>
+- * <p>Document (.tvd) --&gt; TVDVersion&lt;NumFields, FieldNums,
++ * <p>Document (.tvd) --&gt; Header,&lt;NumFields, FieldNums,
+  * FieldPositions&gt; <sup>NumDocs</sup></p>
+  * <ul>
+- *   <li>TVDVersion --&gt; {@link DataOutput#writeInt Int32} (<code>Lucene40TermVectorsReader.FORMAT_CURRENT</code>)</li>
++ *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}</li>
+  *   <li>NumFields --&gt; {@link DataOutput#writeVInt VInt}</li>
+  *   <li>FieldNums --&gt; &lt;FieldNumDelta&gt; <sup>NumFields</sup></li>
+  *   <li>FieldNumDelta --&gt; {@link DataOutput#writeVInt VInt}</li>
+@@ -69,10 +70,10 @@ import org.apache.lucene.store.IOContext;
+  * <p>This file contains, for each field that has a term vector stored, a list of
+  * the terms, their frequencies and, optionally, position and offset
+  * information.</p>
+- * <p>Field (.tvf) --&gt; TVFVersion&lt;NumTerms, Position/Offset, TermFreqs&gt;
++ * <p>Field (.tvf) --&gt; Header,&lt;NumTerms, Position/Offset, TermFreqs&gt;
+  * <sup>NumFields</sup></p>
+  * <ul>
+- *   <li>TVFVersion --&gt; {@link DataOutput#writeInt Int32} (<code>Lucene40TermVectorsReader.FORMAT_CURRENT</code>)</li>
++ *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}</li>
+  *   <li>NumTerms --&gt; {@link DataOutput#writeVInt VInt}</li>
+  *   <li>Position/Offset --&gt; {@link DataOutput#writeByte Byte}</li>
+  *   <li>TermFreqs --&gt; &lt;TermText, TermFreq, Positions?, Offsets?&gt;
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsReader.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsReader.java
+index e44713b..c0bee03 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsReader.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsReader.java
+@@ -33,8 +33,6 @@ import org.apache.lucene.index.FieldInfos;
+ import org.apache.lucene.index.Fields;
+ import org.apache.lucene.index.FieldsEnum;
+ import org.apache.lucene.index.IndexFileNames;
+-import org.apache.lucene.index.IndexFormatTooNewException;
+-import org.apache.lucene.index.IndexFormatTooOldException;
+ import org.apache.lucene.index.SegmentInfo;
+ import org.apache.lucene.index.Terms;
+ import org.apache.lucene.index.TermsEnum;
+@@ -43,8 +41,10 @@ import org.apache.lucene.store.IOContext;
+ import org.apache.lucene.store.IndexInput;
+ import org.apache.lucene.util.Bits;
+ import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.CodecUtil;
+ import org.apache.lucene.util.IOUtils;
+ 
++
+ /**
+  * Lucene 4.0 Term Vectors reader.
+  * <p>
+@@ -54,22 +54,6 @@ import org.apache.lucene.util.IOUtils;
+  */
+ public class Lucene40TermVectorsReader extends TermVectorsReader {
+ 
+-  // NOTE: if you make a new format, it must be larger than
+-  // the current format
+-
+-  // Changed strings to UTF8 with length-in-bytes not length-in-chars
+-  static final int FORMAT_UTF8_LENGTH_IN_BYTES = 4;
+-
+-  // NOTE: always change this if you switch to a new format!
+-  // whenever you add a new format, make it 1 larger (positive version logic)!
+-  static final int FORMAT_CURRENT = FORMAT_UTF8_LENGTH_IN_BYTES;
+-  
+-  // when removing support for old versions, leave the last supported version here
+-  static final int FORMAT_MINIMUM = FORMAT_UTF8_LENGTH_IN_BYTES;
+-
+-  //The size in bytes that the FORMAT_VERSION will take up at the beginning of each file 
+-  static final int FORMAT_SIZE = 4;
+-
+   static final byte STORE_POSITIONS_WITH_TERMVECTOR = 0x1;
+ 
+   static final byte STORE_OFFSET_WITH_TERMVECTOR = 0x2;
+@@ -82,6 +66,17 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+ 
+   /** Extension of vectors index file */
+   static final String VECTORS_INDEX_EXTENSION = "tvx";
++  
++  static final String CODEC_NAME_FIELDS = "Lucene40TermVectorsFields";
++  static final String CODEC_NAME_DOCS = "Lucene40TermVectorsDocs";
++  static final String CODEC_NAME_INDEX = "Lucene40TermVectorsIndex";
++
++  static final int VERSION_START = 0;
++  static final int VERSION_CURRENT = VERSION_START;
++  
++  static final long HEADER_LENGTH_FIELDS = CodecUtil.headerLength(CODEC_NAME_FIELDS);
++  static final long HEADER_LENGTH_DOCS = CodecUtil.headerLength(CODEC_NAME_DOCS);
++  static final long HEADER_LENGTH_INDEX = CodecUtil.headerLength(CODEC_NAME_INDEX);
+ 
+   private FieldInfos fieldInfos;
+ 
+@@ -91,17 +86,15 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+   private int size;
+   private int numTotalDocs;
+   
+-  private final int format;
+ 
+   // used by clone
+-  Lucene40TermVectorsReader(FieldInfos fieldInfos, IndexInput tvx, IndexInput tvd, IndexInput tvf, int size, int numTotalDocs, int format) {
++  Lucene40TermVectorsReader(FieldInfos fieldInfos, IndexInput tvx, IndexInput tvd, IndexInput tvf, int size, int numTotalDocs) {
+     this.fieldInfos = fieldInfos;
+     this.tvx = tvx;
+     this.tvd = tvd;
+     this.tvf = tvf;
+     this.size = size;
+     this.numTotalDocs = numTotalDocs;
+-    this.format = format;
+   }
+     
+   public Lucene40TermVectorsReader(Directory d, SegmentInfo si, FieldInfos fieldInfos, IOContext context)
+@@ -114,18 +107,21 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+     try {
+       String idxName = IndexFileNames.segmentFileName(segment, "", VECTORS_INDEX_EXTENSION);
+       tvx = d.openInput(idxName, context);
+-      format = checkValidFormat(tvx);
++      final int tvxVersion = CodecUtil.checkHeader(tvx, CODEC_NAME_INDEX, VERSION_START, VERSION_CURRENT);
++      
+       String fn = IndexFileNames.segmentFileName(segment, "", VECTORS_DOCUMENTS_EXTENSION);
+       tvd = d.openInput(fn, context);
+-      final int tvdFormat = checkValidFormat(tvd);
++      final int tvdVersion = CodecUtil.checkHeader(tvd, CODEC_NAME_DOCS, VERSION_START, VERSION_CURRENT);
+       fn = IndexFileNames.segmentFileName(segment, "", VECTORS_FIELDS_EXTENSION);
+       tvf = d.openInput(fn, context);
+-      final int tvfFormat = checkValidFormat(tvf);
++      final int tvfVersion = CodecUtil.checkHeader(tvf, CODEC_NAME_FIELDS, VERSION_START, VERSION_CURRENT);
++      assert HEADER_LENGTH_INDEX == tvx.getFilePointer();
++      assert HEADER_LENGTH_DOCS == tvd.getFilePointer();
++      assert HEADER_LENGTH_FIELDS == tvf.getFilePointer();
++      assert tvxVersion == tvdVersion;
++      assert tvxVersion == tvfVersion;
+ 
+-      assert format == tvdFormat;
+-      assert format == tvfFormat;
+-
+-      numTotalDocs = (int) (tvx.length() >> 4);
++      numTotalDocs = (int) (tvx.length()-HEADER_LENGTH_INDEX >> 4);
+ 
+       this.size = numTotalDocs;
+       assert size == 0 || numTotalDocs == size;
+@@ -156,13 +152,7 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+ 
+   // Not private to avoid synthetic access$NNN methods
+   void seekTvx(final int docNum) throws IOException {
+-    tvx.seek(docNum * 16L + FORMAT_SIZE);
+-  }
+-
+-  boolean canReadRawDocs() {
+-    // we can always read raw docs, unless the term vectors
+-    // didn't exist
+-    return format != 0;
++    tvx.seek(docNum * 16L + HEADER_LENGTH_INDEX);
+   }
+ 
+   /** Retrieve the length (in bytes) of the tvd and tvf
+@@ -210,16 +200,6 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+     }
+   }
+ 
+-  private int checkValidFormat(IndexInput in) throws CorruptIndexException, IOException
+-  {
+-    int format = in.readInt();
+-    if (format < FORMAT_MINIMUM)
+-      throw new IndexFormatTooOldException(in, format, FORMAT_MINIMUM, FORMAT_CURRENT);
+-    if (format > FORMAT_CURRENT)
+-      throw new IndexFormatTooNewException(in, format, FORMAT_MINIMUM, FORMAT_CURRENT);
+-    return format;
+-  }
+-
+   public void close() throws IOException {
+     IOUtils.close(tvx, tvd, tvf);
+   }
+@@ -708,7 +688,7 @@ public class Lucene40TermVectorsReader extends TermVectorsReader {
+       cloneTvf = (IndexInput) tvf.clone();
+     }
+     
+-    return new Lucene40TermVectorsReader(fieldInfos, cloneTvx, cloneTvd, cloneTvf, size, numTotalDocs, format);
++    return new Lucene40TermVectorsReader(fieldInfos, cloneTvx, cloneTvd, cloneTvf, size, numTotalDocs);
+   }
+   
+   public static void files(SegmentInfo info, Set<String> files) throws IOException {
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsWriter.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsWriter.java
+index 372db23..a61c321 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsWriter.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/Lucene40TermVectorsWriter.java
+@@ -35,9 +35,13 @@ import org.apache.lucene.store.IndexOutput;
+ import org.apache.lucene.util.ArrayUtil;
+ import org.apache.lucene.util.Bits;
+ import org.apache.lucene.util.BytesRef;
++import org.apache.lucene.util.CodecUtil;
+ import org.apache.lucene.util.IOUtils;
+ import org.apache.lucene.util.StringHelper;
+ 
++import static org.apache.lucene.codecs.lucene40.Lucene40TermVectorsReader.*;
++
++
+ // TODO: make a new 4.0 TV format that encodes better
+ //   - use startOffset (not endOffset) as base for delta on
+ //     next startOffset because today for syns or ngrams or
+@@ -58,6 +62,8 @@ public final class Lucene40TermVectorsWriter extends TermVectorsWriter {
+   private final Directory directory;
+   private final String segment;
+   private IndexOutput tvx = null, tvd = null, tvf = null;
++  
++ 
+ 
+   public Lucene40TermVectorsWriter(Directory directory, String segment, IOContext context) throws IOException {
+     this.directory = directory;
+@@ -66,11 +72,14 @@ public final class Lucene40TermVectorsWriter extends TermVectorsWriter {
+     try {
+       // Open files for TermVector storage
+       tvx = directory.createOutput(IndexFileNames.segmentFileName(segment, "", Lucene40TermVectorsReader.VECTORS_INDEX_EXTENSION), context);
+-      tvx.writeInt(Lucene40TermVectorsReader.FORMAT_CURRENT);
++      CodecUtil.writeHeader(tvx, CODEC_NAME_INDEX, VERSION_CURRENT);
+       tvd = directory.createOutput(IndexFileNames.segmentFileName(segment, "", Lucene40TermVectorsReader.VECTORS_DOCUMENTS_EXTENSION), context);
+-      tvd.writeInt(Lucene40TermVectorsReader.FORMAT_CURRENT);
++      CodecUtil.writeHeader(tvd, CODEC_NAME_DOCS, VERSION_CURRENT);
+       tvf = directory.createOutput(IndexFileNames.segmentFileName(segment, "", Lucene40TermVectorsReader.VECTORS_FIELDS_EXTENSION), context);
+-      tvf.writeInt(Lucene40TermVectorsReader.FORMAT_CURRENT);
++      CodecUtil.writeHeader(tvf, CODEC_NAME_FIELDS, VERSION_CURRENT);
++      assert HEADER_LENGTH_INDEX == tvx.getFilePointer();
++      assert HEADER_LENGTH_DOCS == tvd.getFilePointer();
++      assert HEADER_LENGTH_FIELDS == tvf.getFilePointer();
+       success = true;
+     } finally {
+       if (!success) {
+@@ -252,10 +261,7 @@ public final class Lucene40TermVectorsWriter extends TermVectorsWriter {
+         TermVectorsReader vectorsReader = matchingSegmentReader.getTermVectorsReader();
+ 
+         if (vectorsReader != null && vectorsReader instanceof Lucene40TermVectorsReader) {
+-          // If the TV* files are an older format then they cannot read raw docs:
+-          if (((Lucene40TermVectorsReader)vectorsReader).canReadRawDocs()) {
+             matchingVectorsReader = (Lucene40TermVectorsReader) vectorsReader;
+-          }
+         }
+       }
+       if (reader.liveDocs != null) {
+@@ -356,7 +362,7 @@ public final class Lucene40TermVectorsWriter extends TermVectorsWriter {
+   
+   @Override
+   public void finish(int numDocs) throws IOException {
+-    if (4+((long) numDocs)*16 != tvx.getFilePointer())
++    if (HEADER_LENGTH_INDEX+((long) numDocs)*16 != tvx.getFilePointer())
+       // This is most likely a bug in Sun JRE 1.6.0_04/_05;
+       // we detect that the bug has struck, here, and
+       // throw an exception to prevent the corruption from
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/Bytes.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/Bytes.java
+index 4ab1bd9..7fb3dd9 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/Bytes.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/Bytes.java
+@@ -236,27 +236,34 @@ public final class Bytes {
+     private IndexOutput datOut;
+     protected BytesRef bytesRef = new BytesRef();
+     private final Directory dir;
+-    private final String codecName;
++    private final String codecNameIdx;
++    private final String codecNameDat;
+     private final int version;
+     private final IOContext context;
+ 
+-    protected BytesWriterBase(Directory dir, String id, String codecName,
++    protected BytesWriterBase(Directory dir, String id, String codecNameIdx, String codecNameDat,
+         int version, Counter bytesUsed, IOContext context, Type type) throws IOException {
+       super(bytesUsed, type);
+       this.id = id;
+       this.dir = dir;
+-      this.codecName = codecName;
++      this.codecNameIdx = codecNameIdx;
++      this.codecNameDat = codecNameDat;
+       this.version = version;
+       this.context = context;
++      assert codecNameDat != null || codecNameIdx != null: "both codec names are null";
++      assert (codecNameDat != null && !codecNameDat.equals(codecNameIdx)) 
++      || (codecNameIdx != null && !codecNameIdx.equals(codecNameDat)):
++        "index and data codec names must not be equal";
+     }
+     
+     protected IndexOutput getOrCreateDataOut() throws IOException {
+       if (datOut == null) {
+         boolean success = false;
++        assert codecNameDat != null;
+         try {
+           datOut = dir.createOutput(IndexFileNames.segmentFileName(id, DV_SEGMENT_SUFFIX,
+               DocValuesWriterBase.DATA_EXTENSION), context);
+-          CodecUtil.writeHeader(datOut, codecName, version);
++          CodecUtil.writeHeader(datOut, codecNameDat, version);
+           success = true;
+         } finally {
+           if (!success) {
+@@ -279,9 +286,10 @@ public final class Bytes {
+       boolean success = false;
+       try {
+         if (idxOut == null) {
++          assert codecNameIdx != null;
+           idxOut = dir.createOutput(IndexFileNames.segmentFileName(id, DV_SEGMENT_SUFFIX,
+               DocValuesWriterBase.INDEX_EXTENSION), context);
+-          CodecUtil.writeHeader(idxOut, codecName, version);
++          CodecUtil.writeHeader(idxOut, codecNameIdx, version);
+         }
+         success = true;
+       } finally {
+@@ -308,8 +316,8 @@ public final class Bytes {
+     protected final int version;
+     protected final String id;
+     protected final Type type;
+-
+-    protected BytesReaderBase(Directory dir, String id, String codecName,
++    
++    protected BytesReaderBase(Directory dir, String id, String codecNameIdx, String codecNameDat,
+         int maxVersion, boolean doIndex, IOContext context, Type type) throws IOException {
+       IndexInput dataIn = null;
+       IndexInput indexIn = null;
+@@ -317,11 +325,11 @@ public final class Bytes {
+       try {
+         dataIn = dir.openInput(IndexFileNames.segmentFileName(id, DV_SEGMENT_SUFFIX,
+                                                               DocValuesWriterBase.DATA_EXTENSION), context);
+-        version = CodecUtil.checkHeader(dataIn, codecName, maxVersion, maxVersion);
++        version = CodecUtil.checkHeader(dataIn, codecNameDat, maxVersion, maxVersion);
+         if (doIndex) {
+           indexIn = dir.openInput(IndexFileNames.segmentFileName(id, DV_SEGMENT_SUFFIX,
+                                                                  DocValuesWriterBase.INDEX_EXTENSION), context);
+-          final int version2 = CodecUtil.checkHeader(indexIn, codecName,
++          final int version2 = CodecUtil.checkHeader(indexIn, codecNameIdx,
+                                                      maxVersion, maxVersion);
+           assert version == version2;
+         }
+@@ -377,23 +385,23 @@ public final class Bytes {
+     protected final boolean fasterButMoreRam;
+     protected long maxBytes = 0;
+     
+-    protected DerefBytesWriterBase(Directory dir, String id, String codecName,
++    protected DerefBytesWriterBase(Directory dir, String id, String codecNameIdx, String codecNameDat,
+         int codecVersion, Counter bytesUsed, IOContext context, Type type)
+         throws IOException {
+-      this(dir, id, codecName, codecVersion, new DirectTrackingAllocator(
++      this(dir, id, codecNameIdx, codecNameDat, codecVersion, new DirectTrackingAllocator(
+           ByteBlockPool.BYTE_BLOCK_SIZE, bytesUsed), bytesUsed, context, false, type);
+     }
+ 
+-    protected DerefBytesWriterBase(Directory dir, String id, String codecName,
++    protected DerefBytesWriterBase(Directory dir, String id, String codecNameIdx, String codecNameDat,
+                                    int codecVersion, Counter bytesUsed, IOContext context, boolean fasterButMoreRam, Type type)
+         throws IOException {
+-      this(dir, id, codecName, codecVersion, new DirectTrackingAllocator(
++      this(dir, id, codecNameIdx, codecNameDat, codecVersion, new DirectTrackingAllocator(
+           ByteBlockPool.BYTE_BLOCK_SIZE, bytesUsed), bytesUsed, context, fasterButMoreRam,type);
+     }
+ 
+-    protected DerefBytesWriterBase(Directory dir, String id, String codecName, int codecVersion, Allocator allocator,
++    protected DerefBytesWriterBase(Directory dir, String id, String codecNameIdx, String codecNameDat, int codecVersion, Allocator allocator,
+         Counter bytesUsed, IOContext context, boolean fasterButMoreRam, Type type) throws IOException {
+-      super(dir, id, codecName, codecVersion, bytesUsed, context, type);
++      super(dir, id, codecNameIdx, codecNameDat, codecVersion, bytesUsed, context, type);
+       hash = new BytesRefHash(new ByteBlockPool(allocator),
+           BytesRefHash.DEFAULT_CAPACITY, new TrackingDirectBytesStartArray(
+               BytesRefHash.DEFAULT_CAPACITY, bytesUsed));
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedDerefBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedDerefBytesImpl.java
+index 7c745b9..c7e8740 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedDerefBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedDerefBytesImpl.java
+@@ -39,14 +39,16 @@ import org.apache.lucene.util.packed.PackedInts;
+  */
+ class FixedDerefBytesImpl {
+ 
+-  static final String CODEC_NAME = "FixedDerefBytes";
++  static final String CODEC_NAME_IDX = "FixedDerefBytesIdx";
++  static final String CODEC_NAME_DAT = "FixedDerefBytesDat";
++
+   static final int VERSION_START = 0;
+   static final int VERSION_CURRENT = VERSION_START;
+ 
+   public static class Writer extends DerefBytesWriterBase {
+     public Writer(Directory dir, String id, Counter bytesUsed, IOContext context)
+         throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context, Type.BYTES_FIXED_DEREF);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_CURRENT, bytesUsed, context, Type.BYTES_FIXED_DEREF);
+     }
+ 
+     @Override
+@@ -71,7 +73,7 @@ class FixedDerefBytesImpl {
+     private final int size;
+     private final int numValuesStored;
+     FixedDerefReader(Directory dir, String id, int maxDoc, IOContext context) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_START, true, context, Type.BYTES_FIXED_DEREF);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_START, true, context, Type.BYTES_FIXED_DEREF);
+       size = datIn.readInt();
+       numValuesStored = idxIn.readInt();
+     }
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedSortedBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedSortedBytesImpl.java
+index 2ab1700..278cb89 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedSortedBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedSortedBytesImpl.java
+@@ -49,7 +49,8 @@ import org.apache.lucene.util.packed.PackedInts;
+  */
+ class FixedSortedBytesImpl {
+ 
+-  static final String CODEC_NAME = "FixedSortedBytes";
++  static final String CODEC_NAME_IDX = "FixedSortedBytesIdx";
++  static final String CODEC_NAME_DAT = "FixedSortedBytesDat";
+   static final int VERSION_START = 0;
+   static final int VERSION_CURRENT = VERSION_START;
+ 
+@@ -58,7 +59,7 @@ class FixedSortedBytesImpl {
+ 
+     public Writer(Directory dir, String id, Comparator<BytesRef> comp,
+         Counter bytesUsed, IOContext context, boolean fasterButMoreRam) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context, fasterButMoreRam, Type.BYTES_FIXED_SORTED);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_CURRENT, bytesUsed, context, fasterButMoreRam, Type.BYTES_FIXED_SORTED);
+       this.comp = comp;
+     }
+ 
+@@ -127,7 +128,7 @@ class FixedSortedBytesImpl {
+ 
+     public Reader(Directory dir, String id, int maxDoc, IOContext context,
+         Type type, Comparator<BytesRef> comparator) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_START, true, context, type);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_START, true, context, type);
+       size = datIn.readInt();
+       valueCount = idxIn.readInt();
+       this.comparator = comparator;
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedStraightBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedStraightBytesImpl.java
+index fd779ae..ced34f3 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedStraightBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/FixedStraightBytesImpl.java
+@@ -61,14 +61,14 @@ class FixedStraightBytesImpl {
+     private final int byteBlockSize = BYTE_BLOCK_SIZE;
+     private final ByteBlockPool pool;
+ 
+-    protected FixedBytesWriterBase(Directory dir, String id, String codecName,
++    protected FixedBytesWriterBase(Directory dir, String id, String codecNameDat,
+         int version, Counter bytesUsed, IOContext context) throws IOException {
+-     this(dir, id, codecName, version, bytesUsed, context, Type.BYTES_FIXED_STRAIGHT);
++     this(dir, id, codecNameDat, version, bytesUsed, context, Type.BYTES_FIXED_STRAIGHT);
+     }
+     
+-    protected FixedBytesWriterBase(Directory dir, String id, String codecName,
++    protected FixedBytesWriterBase(Directory dir, String id, String codecNameDat,
+         int version, Counter bytesUsed, IOContext context, Type type) throws IOException {
+-      super(dir, id, codecName, version, bytesUsed, context, type);
++      super(dir, id, null, codecNameDat, version, bytesUsed, context, type);
+       pool = new ByteBlockPool(new DirectTrackingAllocator(bytesUsed));
+       pool.nextBuffer();
+     }
+@@ -139,8 +139,8 @@ class FixedStraightBytesImpl {
+       super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context);
+     }
+ 
+-    public Writer(Directory dir, String id, String codecName, int version, Counter bytesUsed, IOContext context) throws IOException {
+-      super(dir, id, codecName, version, bytesUsed, context);
++    public Writer(Directory dir, String id, String codecNameDat, int version, Counter bytesUsed, IOContext context) throws IOException {
++      super(dir, id, codecNameDat, version, bytesUsed, context);
+     }
+ 
+ 
+@@ -268,8 +268,8 @@ class FixedStraightBytesImpl {
+       this(dir, id, CODEC_NAME, VERSION_CURRENT, maxDoc, context, Type.BYTES_FIXED_STRAIGHT);
+     }
+ 
+-    protected FixedStraightReader(Directory dir, String id, String codec, int version, int maxDoc, IOContext context, Type type) throws IOException {
+-      super(dir, id, codec, version, false, context, type);
++    protected FixedStraightReader(Directory dir, String id, String codecNameDat, int version, int maxDoc, IOContext context, Type type) throws IOException {
++      super(dir, id, null, codecNameDat, version, false, context, type);
+       size = datIn.readInt();
+       this.maxDoc = maxDoc;
+     }
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarDerefBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarDerefBytesImpl.java
+index 43bff79..fa46bf6 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarDerefBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarDerefBytesImpl.java
+@@ -41,7 +41,9 @@ import org.apache.lucene.util.packed.PackedInts;
+  */
+ class VarDerefBytesImpl {
+ 
+-  static final String CODEC_NAME = "VarDerefBytes";
++  static final String CODEC_NAME_IDX = "VarDerefBytesIdx";
++  static final String CODEC_NAME_DAT = "VarDerefBytesDat";
++
+   static final int VERSION_START = 0;
+   static final int VERSION_CURRENT = VERSION_START;
+ 
+@@ -57,7 +59,7 @@ class VarDerefBytesImpl {
+   static class Writer extends DerefBytesWriterBase {
+     public Writer(Directory dir, String id, Counter bytesUsed, IOContext context)
+         throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context, Type.BYTES_VAR_DEREF);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_CURRENT, bytesUsed, context, Type.BYTES_VAR_DEREF);
+       size = 0;
+     }
+     
+@@ -93,7 +95,7 @@ class VarDerefBytesImpl {
+   public static class VarDerefReader extends BytesReaderBase {
+     private final long totalBytes;
+     VarDerefReader(Directory dir, String id, int maxDoc, IOContext context) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_START, true, context, Type.BYTES_VAR_DEREF);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_START, true, context, Type.BYTES_VAR_DEREF);
+       totalBytes = idxIn.readLong();
+     }
+ 
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarSortedBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarSortedBytesImpl.java
+index 9a8e87d..87c3f65 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarSortedBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarSortedBytesImpl.java
+@@ -50,7 +50,9 @@ import org.apache.lucene.util.packed.PackedInts;
+  */
+ final class VarSortedBytesImpl {
+ 
+-  static final String CODEC_NAME = "VarDerefBytes";
++  static final String CODEC_NAME_IDX = "VarDerefBytesIdx";
++  static final String CODEC_NAME_DAT = "VarDerefBytesDat";
++
+   static final int VERSION_START = 0;
+   static final int VERSION_CURRENT = VERSION_START;
+ 
+@@ -59,7 +61,7 @@ final class VarSortedBytesImpl {
+ 
+     public Writer(Directory dir, String id, Comparator<BytesRef> comp,
+         Counter bytesUsed, IOContext context, boolean fasterButMoreRam) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context, fasterButMoreRam, Type.BYTES_VAR_SORTED);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_CURRENT, bytesUsed, context, fasterButMoreRam, Type.BYTES_VAR_SORTED);
+       this.comp = comp;
+       size = 0;
+     }
+@@ -154,7 +156,7 @@ final class VarSortedBytesImpl {
+     Reader(Directory dir, String id, int maxDoc,
+         IOContext context, Type type, Comparator<BytesRef> comparator)
+         throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_START, true, context, type);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_START, true, context, type);
+       this.comparator = comparator;
+     }
+ 
+diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarStraightBytesImpl.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarStraightBytesImpl.java
+index cfb9d78..ba18691 100644
+--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarStraightBytesImpl.java
++++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene40/values/VarStraightBytesImpl.java
+@@ -50,7 +50,9 @@ import org.apache.lucene.util.packed.PackedInts;
+  */
+ class VarStraightBytesImpl {
+ 
+-  static final String CODEC_NAME = "VarStraightBytes";
++  static final String CODEC_NAME_IDX = "VarStraightBytesIdx";
++  static final String CODEC_NAME_DAT = "VarStraightBytesDat";
++
+   static final int VERSION_START = 0;
+   static final int VERSION_CURRENT = VERSION_START;
+ 
+@@ -64,7 +66,7 @@ class VarStraightBytesImpl {
+     private boolean merge = false;
+     public Writer(Directory dir, String id, Counter bytesUsed, IOContext context)
+         throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_CURRENT, bytesUsed, context, Type.BYTES_VAR_STRAIGHT);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_CURRENT, bytesUsed, context, Type.BYTES_VAR_STRAIGHT);
+       pool = new ByteBlockPool(new DirectTrackingAllocator(bytesUsed));
+       docToAddress = new long[1];
+       pool.nextBuffer(); // init
+@@ -236,7 +238,7 @@ class VarStraightBytesImpl {
+     final int maxDoc;
+ 
+     VarStraightReader(Directory dir, String id, int maxDoc, IOContext context) throws IOException {
+-      super(dir, id, CODEC_NAME, VERSION_START, true, context, Type.BYTES_VAR_STRAIGHT);
++      super(dir, id, CODEC_NAME_IDX, CODEC_NAME_DAT, VERSION_START, true, context, Type.BYTES_VAR_STRAIGHT);
+       this.maxDoc = maxDoc;
+     }
+ 
diff --git a/attachments/LUCENE-9221/LuceneLogo.png b/attachments/LUCENE-9221/LuceneLogo.png
new file mode 100644
index 0000000..2a3a5a4
--- /dev/null
+++ b/attachments/LUCENE-9221/LuceneLogo.png
Binary files differ
diff --git a/attachments/LUCENE-9221/Screen Shot 2020-04-10 at 8.29.32 AM.png b/attachments/LUCENE-9221/Screen Shot 2020-04-10 at 8.29.32 AM.png
new file mode 100644
index 0000000..d9bc96c
--- /dev/null
+++ b/attachments/LUCENE-9221/Screen Shot 2020-04-10 at 8.29.32 AM.png
Binary files differ
diff --git a/attachments/LUCENE-9221/image-2020-04-10-07-04-00-267.png b/attachments/LUCENE-9221/image-2020-04-10-07-04-00-267.png
new file mode 100644
index 0000000..f38a0f6
--- /dev/null
+++ b/attachments/LUCENE-9221/image-2020-04-10-07-04-00-267.png
Binary files differ
diff --git a/attachments/LUCENE-9221/image.png b/attachments/LUCENE-9221/image.png
new file mode 100644
index 0000000..f7c6c60
--- /dev/null
+++ b/attachments/LUCENE-9221/image.png
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene-invert-a.png b/attachments/LUCENE-9221/lucene-invert-a.png
new file mode 100644
index 0000000..c19ca2b
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene-invert-a.png
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo1.pdf b/attachments/LUCENE-9221/lucene_logo1.pdf
new file mode 100644
index 0000000..408f16d
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo1.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo1_full.pdf b/attachments/LUCENE-9221/lucene_logo1_full.pdf
new file mode 100644
index 0000000..ffa523a
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo1_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo2.pdf b/attachments/LUCENE-9221/lucene_logo2.pdf
new file mode 100644
index 0000000..5761e8c
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo2.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo2_full.pdf b/attachments/LUCENE-9221/lucene_logo2_full.pdf
new file mode 100644
index 0000000..d94b001
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo2_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo3.pdf b/attachments/LUCENE-9221/lucene_logo3.pdf
new file mode 100644
index 0000000..4cdb17d
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo3.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo3_1.pdf b/attachments/LUCENE-9221/lucene_logo3_1.pdf
new file mode 100644
index 0000000..3f0bf82
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo3_1.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo3_full.pdf b/attachments/LUCENE-9221/lucene_logo3_full.pdf
new file mode 100644
index 0000000..d9455dd
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo3_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo4.pdf b/attachments/LUCENE-9221/lucene_logo4.pdf
new file mode 100644
index 0000000..5e23e8d
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo4.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo4_full.pdf b/attachments/LUCENE-9221/lucene_logo4_full.pdf
new file mode 100644
index 0000000..a395fa8
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo4_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo5.pdf b/attachments/LUCENE-9221/lucene_logo5.pdf
new file mode 100644
index 0000000..fcddb4c
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo5.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo5_full.pdf b/attachments/LUCENE-9221/lucene_logo5_full.pdf
new file mode 100644
index 0000000..997acc5
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo5_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo6.pdf b/attachments/LUCENE-9221/lucene_logo6.pdf
new file mode 100644
index 0000000..fc51468
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo6.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo6_full.pdf b/attachments/LUCENE-9221/lucene_logo6_full.pdf
new file mode 100644
index 0000000..32b9c4e
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo6_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo7.pdf b/attachments/LUCENE-9221/lucene_logo7.pdf
new file mode 100644
index 0000000..cf2e05b
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo7.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo7_full.pdf b/attachments/LUCENE-9221/lucene_logo7_full.pdf
new file mode 100644
index 0000000..c105001
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo7_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo8.pdf b/attachments/LUCENE-9221/lucene_logo8.pdf
new file mode 100644
index 0000000..0acaadd
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo8.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/lucene_logo8_full.pdf b/attachments/LUCENE-9221/lucene_logo8_full.pdf
new file mode 100644
index 0000000..5424345
--- /dev/null
+++ b/attachments/LUCENE-9221/lucene_logo8_full.pdf
Binary files differ
diff --git a/attachments/LUCENE-9221/zabetak-1-7.pdf b/attachments/LUCENE-9221/zabetak-1-7.pdf
new file mode 100644
index 0000000..3c246a6
--- /dev/null
+++ b/attachments/LUCENE-9221/zabetak-1-7.pdf
Binary files differ
diff --git a/migration/.env.example b/migration/.env.example
new file mode 100644
index 0000000..842824a
--- /dev/null
+++ b/migration/.env.example
@@ -0,0 +1,4 @@
+export GITHUB_PAT=
+export GITHUB_REPO=
+export GITHUB_ATT_REPO="apache/lucene-jira-archive"
+export GITHUB_ATT_BRANCH="main"
\ No newline at end of file
diff --git a/migration/.gitignore b/migration/.gitignore
new file mode 100644
index 0000000..a9d17ea
--- /dev/null
+++ b/migration/.gitignore
@@ -0,0 +1,16 @@
+.vscode/
+.idea/
+
+__pycache__/
+*.py[cod]
+*$py.class
+.venv/
+venv/
+
+*.out
+*.json
+*.csv
+.env
+
+log/
+attachments/
\ No newline at end of file
diff --git a/migration/.python-version b/migration/.python-version
new file mode 100644
index 0000000..21af950
--- /dev/null
+++ b/migration/.python-version
@@ -0,0 +1 @@
+3.9.13
diff --git a/migration/README.md b/migration/README.md
new file mode 100644
index 0000000..7629a79
--- /dev/null
+++ b/migration/README.md
@@ -0,0 +1,126 @@
+# [WIP] Migration tools (Jira issue -> GitHub issue)
+
+## Setup
+
+You need Python 3.9+. The scripts were tested on Linux; maybe works also on Mac and Windows (not tested).
+
+```
+python -V
+Python 3.9.13
+
+# install dependencies
+python -m venv .venv
+source .venv/bin/activate
+(.venv) pip install -r requirements.txt
+```
+
+You need a GitHub repository and personal access token for testing. Set `GITHUB_PAT` and `GITHUB_REPO` environment variables.
+
+```
+export GITHUB_PAT=<your token>
+export GITHUB_REPO=<your repository location> # e.g. "mocobeta/sandbox-lucene-10557"
+```
+
+## Usage
+
+### 1. Download Jira issues
+
+`src/download_jira.py` downloads Jira issues and dumps them as JSON files in `migration/jira-dump`.
+
+```
+(.venv) migration $ python src/download_jira.py --min 10500 --max 10600
+[2022-06-26 01:57:02,408] INFO:download_jira: Downloading Jira issues in /mnt/hdd/repo/sandbox-lucene-10557/migration/jira-dump
+[2022-06-26 01:57:17,843] INFO:download_jira: Done.
+
+(.venv) migration $ cat log/jira2github_import_2022-06-26T01\:34\:22.log 
+[2022-06-26 01:34:22,300] INFO:jira2github_import: Converting Jira issues to GitHub issues in /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data
+[2022-06-26 01:34:23,355] DEBUG:jira2github_import: GitHub issue data created: /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data/GH-LUCENE-10500.json
+[2022-06-26 01:34:23,519] DEBUG:jira2github_import: GitHub issue data created: /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data/GH-LUCENE-10501.json
+[2022-06-26 01:34:24,894] DEBUG:jira2github_import: GitHub issue data created: /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data/GH-LUCENE-10502.json
+...
+```
+
+### 2. Convert Jira issues to GitHub issues
+
+`src/jira2github_import.py` converts Jira dumps into GitHub data that are importable to [issue import API](https://gist.github.com/jonmagic/5282384165e0f86ef105). Converted JSON data is saved in `migration/github-import-data`.
+
+Also this resolves all Jira user ID - GitHub account alignment if the account mapping is given in `mapping-data/account-map.csv`.
+
+```
+(.venv) migration $ python src/jira2github_import.py --min 10500 --max 10600
+[2022-06-26 01:34:22,300] INFO:jira2github_import: Converting Jira issues to GitHub issues in /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data
+[2022-06-26 01:36:27,739] INFO:jira2github_import: Done.
+
+(.venv) migration $ cat log/jira2github_import_2022-06-26T01\:34\:22.log
+[2022-06-26 01:34:22,300] INFO:jira2github_import: Converting Jira issues to GitHub issues in /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data
+[2022-06-26 01:34:23,355] DEBUG:jira2github_import: GitHub issue data created: /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data/GH-LUCENE-10500.json
+[2022-06-26 01:34:23,519] DEBUG:jira2github_import: GitHub issue data created: /mnt/hdd/repo/sandbox-lucene-10557/migration/github-import-data/GH-LUCENE-10501.json
+...
+```
+
+### 3. Import GitHub issues
+
+First pass: `src/import_github_issues.py` imports GitHub issues and comments via issue import API. This also writes Jira issue key - GitHub issue number mappings to a file in migration/mappings-data.
+
+```
+(.venv) migration $ python src/import_github_issues.py --min 10500 --max 10600
+[2022-06-26 01:36:46,749] INFO:import_github_issues: Importing GitHub issues
+[2022-06-26 01:47:35,979] INFO:import_github_issues: Done.
+
+(.venv) migration $ cat log/import_github_issues_2022-06-26T01\:36\:46.log
+[2022-06-26 01:36:46,749] INFO:import_github_issues: Importing GitHub issues
+[2022-06-26 01:36:52,299] DEBUG:import_github_issues: Import GitHub issue https://github.com/mocobeta/migration-test-2/issues/1 was successfully completed.
+[2022-06-26 01:36:57,883] DEBUG:import_github_issues: Import GitHub issue https://github.com/mocobeta/migration-test-2/issues/2 was successfully completed.
+[2022-06-26 01:37:03,405] DEBUG:import_github_issues: Import GitHub issue https://github.com/mocobeta/migration-test-2/issues/3 was successfully completed.
+...
+
+(.venv) migration $ cat mappings-data/issue-map.csv
+JiraKey,GitHubUrl,GitHubNumber
+LUCENE-10500,https://github.com/mocobeta/migration-test-2/issues/1,1
+LUCENE-10501,https://github.com/mocobeta/migration-test-2/issues/2,2
+LUCENE-10502,https://github.com/mocobeta/migration-test-2/issues/3,3
+...
+```
+
+### 4. Update GitHub issues and comments
+
+Second pass: `src/update_issue_links.py` 1) iterates all imported GitHub issue descriptions and comments; 2) embed correct GitHub issue number next to the corresponding Jira issue key with previously created issue mapping; 3) updates them if the texts are changed.
+
+e.g.: if `LUCENE-10500` is mapped to GitHub issue `#100`, then all text fragments `LUCENE-10500`  in issue descriptions and comments will be updated to `LUCENE-10500 (#100)`.
+
+```
+(.venv) migration $ python src/update_issue_links.py
+[2022-06-26 01:59:43,324] INFO:update_issue_links: Updating GitHub issues
+[2022-06-26 02:17:38,332] INFO:update_issue_links: Done.
+
+(.venv) migration $ cat log/update_issue_links_2022-06-26T01\:59\:43.log
+[2022-06-26 01:59:43,324] INFO:update_issue_links: Updating GitHub issues
+[2022-06-26 01:59:45,586] DEBUG:update_issue_links: Issue 1 does not contain any cross-issue links; nothing to do.
+[2022-06-26 01:59:50,062] DEBUG:update_issue_links: # comments in issue 1 = 3
+[2022-06-26 01:59:52,601] DEBUG:update_issue_links: Comment 1166321470 was successfully updated.
+[2022-06-26 01:59:55,164] DEBUG:update_issue_links: Comment 1166321472 was successfully updated.
+[2022-06-26 01:59:55,165] DEBUG:update_issue_links: Comment 1166321473 does not contain any cross-issue links; nothing to do.
+[2022-06-26 01:59:57,426] DEBUG:update_issue_links: Issue 2 does not contain any cross-issue links; nothing to do.
+...
+```
+
+## Already implemented things
+
+You can:
+
+* migrate all texts in issue descriptions and comments to GitHub; browsing/searching old issues should work fine.
+* extract every issue metadata from Jira and port it to labels or issue descriptions (as plain text).
+* map Jira cross-issue link "LUCENE-xxx" to GitHub issue mention "#yyy".
+* map Jira user ids to GitHub accounts if the mapping is given.
+* convert Jira markups to Markdown with parser library.
+   * not perfect - there can be many conversion errors
+
+
+
+## Limitations
+
+You cannot:
+
+* simulate original issue reporters or comment authors; they have to be preserved in free-text forms.
+* migrate attached files (patches, images, etc.) to GitHub; these have to remain in Jira.
+   * it's not allowed to programmatically upload files and attach them to issues.
diff --git a/migration/mappings-data/account-map.csv.example b/migration/mappings-data/account-map.csv.example
new file mode 100644
index 0000000..66836f1
--- /dev/null
+++ b/migration/mappings-data/account-map.csv.example
@@ -0,0 +1 @@
+JiraName,GitHubAccount
\ No newline at end of file
diff --git a/migration/mappings-data/issue-map.csv.example b/migration/mappings-data/issue-map.csv.example
new file mode 100644
index 0000000..3b6811b
--- /dev/null
+++ b/migration/mappings-data/issue-map.csv.example
@@ -0,0 +1 @@
+JiraKey,GitHubUrl,GitHubNumber
\ No newline at end of file
diff --git a/migration/requirements.txt b/migration/requirements.txt
new file mode 100644
index 0000000..559d5b4
--- /dev/null
+++ b/migration/requirements.txt
@@ -0,0 +1,7 @@
+certifi==2022.6.15
+charset-normalizer==2.0.12
+idna==3.3
+jira2markdown==0.2.1
+pyparsing==2.4.7
+requests==2.28.0
+urllib3==1.26.9
diff --git a/migration/src/__init__.py b/migration/src/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/migration/src/__init__.py
diff --git a/migration/src/common.py b/migration/src/common.py
new file mode 100644
index 0000000..a6c373b
--- /dev/null
+++ b/migration/src/common.py
@@ -0,0 +1,151 @@
+from pathlib import Path
+import logging
+from datetime import datetime
+import functools
+import time
+
+
+LOG_DIRNAME = "log"
+
+JIRA_DUMP_DIRNAME = "jira-dump"
+JIRA_ATTACHMENTS_DIRNAME = "attachments"
+GITHUB_IMPORT_DATA_DIRNAME = "github-import-data"
+MAPPINGS_DATA_DIRNAME = "mappings-data"
+
+ISSUE_MAPPING_FILENAME = "issue-map.csv"
+ACCOUNT_MAPPING_FILENAME = "account-map.csv"
+
+ASF_JIRA_BASE_URL = "https://issues.apache.org/jira/browse"
+
+
+logging.basicConfig(level=logging.DEBUG, handlers=[])
+
+def logging_setup(log_dir: Path, name: str) -> logging.Logger:
+    if not log_dir.exists():
+        log_dir.mkdir()
+    formatter = logging.Formatter("[%(asctime)s] %(levelname)s:%(module)s: %(message)s")
+    file_handler = logging.FileHandler(log_dir.joinpath(f'{name}_{datetime.now().isoformat(timespec="seconds")}.log'))
+    file_handler.setLevel(logging.DEBUG)
+    file_handler.setFormatter(formatter)
+    console_handler = logging.StreamHandler()
+    console_handler.setLevel(logging.INFO)
+    console_handler.setFormatter(formatter)
+    logger = logging.getLogger(name)
+    logger.addHandler(file_handler)
+    logger.addHandler(console_handler)
+    return logger
+
+
+def jira_issue_url(issue_id: str) -> str:
+    return ASF_JIRA_BASE_URL + f"/{issue_id}"
+
+
+def jira_issue_id(issue_number: int) -> str:
+    return f"LUCENE-{issue_number}"
+
+
+def jira_dump_file(dump_dir: Path, issue_number: int) -> Path:
+    issue_id = jira_issue_id(issue_number)
+    return dump_dir.joinpath(f"{issue_id}.json")
+
+
+def jira_attachments_dir(data_dir: Path, issue_number: int) -> Path:
+    issue_id = jira_issue_id(issue_number)
+    return data_dir.joinpath(issue_id)
+
+
+def github_data_file(data_dir: Path, issue_number: int) -> Path:
+    issue_id = jira_issue_id(issue_number)
+    return data_dir.joinpath(f"GH-{issue_id}.json")
+
+
+def make_github_title(summary: str, jira_id: str) -> str:
+    return f"{summary} [{jira_id}]"
+
+
+def read_issue_id_map(issue_mapping_file: Path) -> dict[str, int]:
+    id_map = {}
+    with open(issue_mapping_file) as fp:
+        fp.readline()  # skip header
+        for line in fp:
+            cols = line.strip().split(",")
+            if len(cols) < 3:
+                continue
+            id_map[cols[0]] = int(cols[2])  # jira issue key -> github issue number
+    return id_map
+
+
+def read_account_map(account_mapping_file: Path) -> dict[str, str]:
+    id_map = {}
+    with open(account_mapping_file) as fp:
+        fp.readline()  # skip header
+        for line in fp:
+            cols = line.strip().split(",")
+            if len(cols) < 2:
+                continue
+            id_map[cols[0]] = cols[1]  # jira name -> github account
+    return id_map
+
+
+def retry_upto(max_retry: int, interval: float, logger: logging.Logger):
+    def retry(func):
+        @functools.wraps(func)
+        def _retry(*args, **kwargs):
+            retry = 0
+            while retry < max_retry:
+                try:
+                    return func(*args, **kwargs)
+                except Exception as e:
+                    retry += 1
+                    logger.warning(f"Exception raised during function call {func}. error={str(e)} (retry={retry})")
+                    time.sleep(interval)
+                    continue
+            if retry == max_retry:
+                raise MaxRetryLimitExceedException()
+            return None
+        return _retry
+    return retry
+
+
+class MaxRetryLimitExceedException(Exception):
+    pass
+
+
+ISSUE_TYPE_TO_LABEL_MAP = {
+    "Bug": "type:bug",
+    "New Feature": "type:new_feature",
+    "Improvement": "type:enhancement",
+    "Test": "type:test",
+    "Wish": "type:enhancement",
+    "Task": "type:task"
+}
+
+
+COMPONENT_TO_LABEL_MAP = {
+    "core": "component:module/core",
+    "modules/analysis": "component:module/analysis",
+    "modules/benchmark": "component:module/benchmark",
+    "modules/classification": "component:module/classification",
+    "modules/expressions": "component:module/expressions",
+    "modules/facet": "component:module/facet",
+    "modules/grouping": "component:module/grouping",
+    "modules/highlithter": "component:module/highlithter",
+    "modules/join": "component:module/join",
+    "modules/luke": "component:module/luke",
+    "modules/monitor": "component:module/monitor",
+    "modules/queryparser": "component:module/queryparser",
+    "modules/replicator": "component:module/replicator",
+    "modules/sandbox": "component:module/sandbox",
+    "modules/spatial": "component:module/spatial",
+    "modules/spatial-extras": "component:module/spatial-extras",
+    "modules/spatial3d": "component:module/spatial3d",
+    "modules/suggest": "component:module/suggest",
+    "modules/spellchecker": "component:module/suggest",
+    "modules/test-framework": "component:module/test-framework",
+    "luke": "component:module/luke",
+    "general/build": "component:general/build",
+    "general/javadocs": "component:general/javadocs",
+    "general/test": "component:general/test",
+    "general/website": "component:general/website",
+    "release wizard": "component:general/release wizard",
+}
\ No newline at end of file
diff --git a/migration/src/download_jira.py b/migration/src/download_jira.py
new file mode 100644
index 0000000..be3b73d
--- /dev/null
+++ b/migration/src/download_jira.py
@@ -0,0 +1,120 @@
+#
+# Create local dump of Jira issues 
+# Usage:
+#   python src/download_jira.py --issues <issue number list>
+#   python src/download_jira.py --min <min issue number> --max <max issue number>
+#
+
+import argparse
+from pathlib import Path
+import json
+import time
+from dataclasses import dataclass
+
+import requests
+
+from common import LOG_DIRNAME, JIRA_DUMP_DIRNAME, JIRA_ATTACHMENTS_DIRNAME, logging_setup, jira_dump_file, jira_attachments_dir, jira_issue_id
+
+log_dir = Path(__file__).resolve().parent.parent.joinpath(LOG_DIRNAME)
+logger = logging_setup(log_dir, "download_jira")
+
+DOWNLOAD_INTERVAL_SEC = 0.5
+
+
+@dataclass
+class Attachment(object):
+    filename: str
+    created: str
+    content: str
+    mime_type: str
+
+
+def issue_uri(issue_id: str) -> str:
+    return f"https://issues.apache.org/jira/rest/api/latest/issue/{issue_id}"
+
+
+def download_issue(num: int, dump_dir: Path) -> bool:
+    issue_id = jira_issue_id(num)
+    uri = issue_uri(issue_id)
+    res = requests.get(uri)
+    if res.status_code != 200:
+        logger.warning(f"Can't download {issue_id}. status code={res.status_code}, message={res.text}")
+        return False
+    dump_file = jira_dump_file(dump_dir, num)
+    with open(dump_file, "w") as fp:
+        json.dump(res.json(), fp, indent=2)
+    logger.debug(f"Jira issue {issue_id} was downloaded in {dump_file}.")
+    return True
+
+
+def download_attachments(num: int, dump_dir: Path, att_data_dir: Path):
+    dump_file = jira_dump_file(dump_dir, num)
+    assert dump_file.exists()
+    attachments_dir = jira_attachments_dir(att_data_dir, num)
+    if not attachments_dir.exists():
+        attachments_dir.mkdir()
+    
+    files: dict[str, Attachment] = {}
+    with open(dump_file) as fp:
+        o = json.load(fp)
+        attachments = o.get("fields").get("attachment")
+        if not attachments:
+            return
+        for a in attachments:
+            filename = a.get("filename")
+            created = a.get("created")
+            content = a.get("content")
+            mime_type = a.get("mimeType")
+            if not (filename and created and content and mime_type):
+                continue
+            if filename not in files or created > files[filename].created:
+                files[filename] = Attachment(filename=filename, created=created, content=content, mime_type=mime_type)
+
+    for (_, a) in files.items():
+        logger.info(f"Downloading attachment {a.filename}")
+        res = requests.get(a.content, headers={"Accept": a.mime_type})
+        if res.status_code != 200:
+            logger.error(f"Failed to download attachment {a.filename} in issue {jira_issue_id(num)}")
+            continue
+        attachment_file = attachments_dir.joinpath(a.filename)
+        with open(attachment_file, "wb") as fp:
+            fp.write(res.content)
+        time.sleep(DOWNLOAD_INTERVAL_SEC)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--issues', type=int, required=False, nargs='*', help='Jira issue number list to be downloaded')    
+    parser.add_argument('--min', type=int, dest='min', required=False, default=1, help='Minimum Jira issue number to be donloaded')
+    parser.add_argument('--max', type=int, dest='max', required=False, help='Maximum Jira issue number to be donloaded')
+    args = parser.parse_args()
+
+    dump_dir = Path(__file__).resolve().parent.parent.joinpath(JIRA_DUMP_DIRNAME)
+    if not dump_dir.exists():
+        dump_dir.mkdir()
+    assert dump_dir.exists()
+
+    att_data_dir = Path(__file__).resolve().parent.parent.joinpath(JIRA_ATTACHMENTS_DIRNAME)
+    if not att_data_dir.exists():
+        att_data_dir.mkdir()
+    assert att_data_dir.exists()
+
+    issues = []
+    if args.issues:
+        issues = args.issues
+    else:
+        if args.max:
+            issues.extend(list(range(args.min, args.max + 1)))
+        else:
+            issues.append(args.min)
+    
+    logger.info(f"Downloading Jira issues in {dump_dir}")
+    for num in issues:
+        download_issue(num, dump_dir)
+        download_attachments(num, dump_dir, att_data_dir)
+        time.sleep(DOWNLOAD_INTERVAL_SEC)
+    
+    logger.info("Done.")
+    
+
+    
diff --git a/migration/src/github_issues_util.py b/migration/src/github_issues_util.py
new file mode 100644
index 0000000..4b48a40
--- /dev/null
+++ b/migration/src/github_issues_util.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass, field
+from typing import Any, Optional
+import time
+from logging import Logger
+import requests
+
+
+GITHUB_API_BASE = "https://api.github.com"
+INTERVAL = 2
+
+
+@dataclass
+class GHIssueComment:
+    id: int
+    body: str
+
+
+def check_authentication(token: str):
+    check_url = GITHUB_API_BASE + "/user"
+    headers = {"Authorization": f"token {token}"}
+    res = requests.get(check_url, headers=headers)
+    assert res.status_code == 200, f"Authentication failed. Please check your GitHub token. status_code={res.status_code}, message={res.text}"
+
+
+def get_issue_body(token: str, repo: str, issue_number: int, logger: Logger) -> Optional[str]:
+    url = GITHUB_API_BASE + f"/repos/{repo}/issues/{issue_number}"
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
+    res = requests.get(url, headers=headers)
+    if res.status_code != 200:
+        logger.error(f"Failed to get issue {issue_number}; status_code={res.status_code}, message={res.text}")
+        return None
+    time.sleep(INTERVAL)
+    return res.json().get("body")
+
+
+def update_issue_body(token: str, repo: str, issue_number: int, body: str, logger: Logger) -> bool:
+    url = GITHUB_API_BASE + f"/repos/{repo}/issues/{issue_number}"
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
+    data = {"body": body}
+    res = requests.patch(url, headers=headers, json=data)
+    if res.status_code != 200:
+        logger.error(f"Failed to update issue {issue_number}; status_code={res.status_code}, message={res.text}")
+        return False
+    time.sleep(INTERVAL)
+    return True
+
+
+def get_issue_comments(token: str, repo: str, issue_number: int, logger: Logger) -> list[GHIssueComment]:
+    url = GITHUB_API_BASE + f"/repos/{repo}/issues/{issue_number}/comments?per_page=100"
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
+    li = []
+    stop = False
+    page = 1
+    while not stop:
+        url_with_paging = url + f"&page={page}"
+        res = requests.get(url_with_paging, headers=headers)
+        if res.status_code != 200:
+            logger.error(f"Failed to get issue comments for {issue_number}; status_code={res.status_code}, message={res.text}")
+            break
+        if not res.json():
+            stop = True
+        for comment in res.json():
+            li.append(GHIssueComment(id=comment.get("id"), body=comment.get("body")))
+        page += 1
+        time.sleep(INTERVAL)
+    return li
+
+
+def update_comment_body(token: str, repo: str, comment_id: int, body: str, logger: Logger) -> bool:
+    url = GITHUB_API_BASE + f"/repos/{repo}/issues/comments/{comment_id}"
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
+    data = {"body": body}
+    res = requests.patch(url, headers=headers, json=data)
+    if res.status_code != 200:
+        logger.error(f"Failed to update comment {comment_id}; status_code={res.status_code}, message={res.text}")
+        return False
+    time.sleep(INTERVAL)
+    return True
+
+
+def import_issue(token: str, repo: str, issue_data: dict, logger: Logger) -> str:
+    url = GITHUB_API_BASE + f"/repos/{repo}/import/issues"
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.golden-comet-preview+json"}
+    res = requests.post(url, headers=headers, json=issue_data)
+    if res.status_code != 202:
+        logger.error(f"Failed to import issue {issue_data['issue']['title']}; status_code={res.status_code}, message={res.text}")
+    time.sleep(INTERVAL)
+    return res.json().get("url")
+
+
+def get_import_status(token: str, url: str, logger: Logger) -> Optional[tuple[str, str, list[Any]]]:
+    headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.golden-comet-preview+json"}
+    res = requests.get(url, headers=headers)
+    if res.status_code != 200:
+        logger.error(f"Failed to get import status for {url}; status code={res.status_code}, message={res.text}")
+        return None
+    return (res.json().get("status"), res.json().get("issue_url", ""), res.json().get("errors", []))
diff --git a/migration/src/import_github_issues.py b/migration/src/import_github_issues.py
new file mode 100644
index 0000000..eddb1a1
--- /dev/null
+++ b/migration/src/import_github_issues.py
@@ -0,0 +1,101 @@
+#
+# Import GitHub issues via Import Issues API (https://gist.github.com/jonmagic/5282384165e0f86ef105)
+# Usage:
+#   python src/import_github_issues.py --issues <issue number list>
+#   python src/import_github_issues.py --min <min issue number> --max <max issue number>
+#
+
+import argparse
+from pathlib import Path
+import json
+import sys
+import os
+import time
+
+from common import LOG_DIRNAME, GITHUB_IMPORT_DATA_DIRNAME, MAPPINGS_DATA_DIRNAME, ISSUE_MAPPING_FILENAME, logging_setup, jira_issue_id, github_data_file, retry_upto, MaxRetryLimitExceedException
+from github_issues_util import *
+
+log_dir = Path(__file__).resolve().parent.parent.joinpath(LOG_DIRNAME)
+logger = logging_setup(log_dir, "import_github_issues")
+
+
+def issue_web_url(repo: str, issue_number: str) -> str:
+    return f"https://github.com/{repo}/issues/{issue_number}"
+
+
+@retry_upto(3, 1.0, logger)
+def import_issue_with_comments(num: int, data_dir: Path, token: str, repo: str) -> Optional[tuple[str, str]]:
+    data_file = github_data_file(data_dir, num)
+    if not data_file.exists():
+        return None
+    with open(data_file) as fp:
+        issue_data = json.load(fp)
+        url = import_issue(token, repo, issue_data, logger)
+        (status, issue_url, errors) = ("pending", "", [])
+        while not status or status == "pending":
+            (status, issue_url, errors) = get_import_status(token, url, logger)
+            time.sleep(3)
+        if status == "imported":
+            issue_number = issue_url.rsplit("/", maxsplit=1)[1]
+            web_url = issue_web_url(repo, issue_number)
+            logger.debug(f"Import GitHub issue {web_url} was successfully completed.")
+            return (web_url, issue_number)
+        else:
+            logger.error(f"Import GitHub issue {data_file} was failed. status={status}, errors={errors}")
+        
+        return None
+
+
+if __name__ == "__main__":
+    github_token = os.getenv("GITHUB_PAT")
+    if not github_token:
+        print("Please set your GitHub token to GITHUB_PAT environment variable.")
+        sys.exit(1)
+    github_repo = os.getenv("GITHUB_REPO")
+    if not github_repo:
+        print("Please set GitHub repo location to GITHUB_REPO environment varialbe.")
+        sys.exit(1)
+
+    check_authentication(github_token)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--issues', type=int, required=False, nargs='*', help='Jira issue number list to be downloaded')
+    parser.add_argument('--min', type=int, dest='min', required=False, default=1, help='Minimum Jira issue number to be converted')
+    parser.add_argument('--max', type=int, dest='max', required=False, help='Maximum Jira issue number to be converted')
+    args = parser.parse_args()
+
+    github_data_dir = Path(__file__).resolve().parent.parent.joinpath(GITHUB_IMPORT_DATA_DIRNAME)
+    if not github_data_dir.exists():
+        logger.error(f"GitHub data dir not exists. {github_data_dir}")
+        sys.exit(1)
+    
+    mapping_data_dir = Path(__file__).resolve().parent.parent.joinpath(MAPPINGS_DATA_DIRNAME)
+    if not mapping_data_dir.exists():
+        mapping_data_dir.mkdir()
+    issue_mapping_file = mapping_data_dir.joinpath(ISSUE_MAPPING_FILENAME)
+    if not issue_mapping_file.exists():
+        with open(issue_mapping_file, "w") as fp:
+            fp.write("JiraKey,GitHubUrl,GitHubNumber\n")   
+    
+    issues = []
+    if args.issues:
+        issues = args.issues
+    else:
+        if args.max:
+            issues.extend(list(range(args.min, args.max + 1)))
+        else:
+            issues.append(args.min)
+
+    logger.info(f"Importing GitHub issues")
+    for num in issues:
+        try:
+            res = import_issue_with_comments(num, github_data_dir, github_token, github_repo)
+            if res:
+                (issue_url, issue_number) = res
+                with open(issue_mapping_file, "a") as fp:
+                    fp.write(f"{jira_issue_id(num)},{issue_url},{issue_number}\n")
+        except MaxRetryLimitExceedException:
+            logger.error(f"Failed to import issue to GitHub. Skipped issue {num}")
+            continue
+
+    logger.info("Done.")
\ No newline at end of file
diff --git a/migration/src/jira2github_import.py b/migration/src/jira2github_import.py
new file mode 100644
index 0000000..6d71a0b
--- /dev/null
+++ b/migration/src/jira2github_import.py
@@ -0,0 +1,216 @@
+#
+# Convert Jira issues to GitHub issues for Import Issues API (https://gist.github.com/jonmagic/5282384165e0f86ef105)
+# Usage:
+#   python src/jira2github_import.py --issues <issue number list>
+#   python src/jira2github_import.py --min <min issue number> --max <max issue number>
+#
+
+import argparse
+from pathlib import Path
+import json
+import sys
+from urllib.parse import quote
+import os
+
+from common import LOG_DIRNAME, JIRA_DUMP_DIRNAME, GITHUB_IMPORT_DATA_DIRNAME, MAPPINGS_DATA_DIRNAME, ACCOUNT_MAPPING_FILENAME, ISSUE_TYPE_TO_LABEL_MAP, COMPONENT_TO_LABEL_MAP, \
+    logging_setup, jira_issue_url, jira_dump_file, jira_issue_id, github_data_file, make_github_title, read_account_map
+from jira_util import *
+
+log_dir = Path(__file__).resolve().parent.parent.joinpath(LOG_DIRNAME)
+logger = logging_setup(log_dir, "jira2github_import")
+
+
+def attachment_url(issue_num: int, filename: str, att_repo: str, att_branch: str) -> str:
+    return f"https://github.com/{att_repo}/blob/{att_branch}/attachments/{jira_issue_id(issue_num)}/{quote(filename)}"
+
+
+def may_markup(gh_account: str) -> bool:
+    return gh_account if gh_account in ["@mocobeta", "@dweiss"] else f"`{gh_account}`"
+
+
+def jira_timestamp_to_github_timestamp(ts: str) -> str:
+    # convert Jira timestamp format to GitHub acceptable format
+    # e.g., "2006-06-06T06:24:38.000+0000" -> "2006-06-06T06:24:38Z"
+    return ts[:-9] + "Z"
+
+
+def convert_issue(num: int, dump_dir: Path, output_dir: Path, account_map: dict[str, str], att_repo: str, att_branch: str) -> bool:
+    jira_id = jira_issue_id(num)
+    dump_file = jira_dump_file(dump_dir, num)
+    if not dump_file.exists():
+        logger.warning(f"Jira dump file not found: {dump_file}")
+        return False
+
+    with open(dump_file) as fp:
+        o = json.load(fp)
+        summary = extract_summary(o).strip()
+        description = extract_description(o).strip()
+        status = extract_status(o)
+        issue_type = extract_issue_type(o)
+        (reporter_name, reporter_dispname) = extract_reporter(o)
+        (assignee_name, assignee_dispname) = extract_assignee(o)
+        created = extract_created(o)
+        updated = extract_updated(o)
+        resolutiondate = extract_resolutiondate(o)
+        fix_versions = extract_fixversions(o)
+        versions = extract_versions(o)
+        components = extract_components(o)
+        attachments = extract_attachments(o)
+        linked_issues = extract_issue_links(o)
+        subtasks = extract_subtasks(o)
+        pull_requests =extract_pull_requests(o)
+
+        reporter_gh = account_map.get(reporter_name)
+        reporter = f"{reporter_dispname} ({may_markup(reporter_gh)})" if reporter_gh else f"{reporter_dispname}"
+        assignee_gh = account_map.get(assignee_name)
+        assignee = f"{assignee_dispname} ({may_markup(assignee_gh)})" if assignee_gh else f"{assignee_dispname}"
+
+        # make attachment list
+        attachment_list_items = []
+        att_replace_map = {}
+        for (filename, cnt) in attachments:
+            attachment_list_items.append(f"- [{filename}]({attachment_url(num, filename, att_repo, att_branch)})" + (f" (versions: {cnt})\n" if cnt > 1 else "\n"))
+            att_replace_map[filename] = attachment_url(num, filename, att_repo, att_branch)
+
+        # embed github issue number next to linked issue keys
+        linked_issues_list_items = []
+        for jira_key in linked_issues:
+            linked_issues_list_items.append(f"- {jira_key} : [Jira link]({jira_issue_url(jira_key)})\n")
+        
+        # embed github issue number next to sub task keys
+        subtasks_list_items = []
+        for jira_key in subtasks:
+            subtasks_list_items.append(f"- {jira_key} : [Jira link]({jira_issue_url(jira_key)})\n")
+
+        # make pull requests list
+        pull_requests_list = [f"- {x}\n" for x in pull_requests]
+
+        body = f"""{convert_text(description, att_replace_map)}
+
+---
+### Jira information
+
+Original Jira: {jira_issue_url(jira_id)}
+Reporter: {reporter}
+Assignee: {assignee}
+Created: {created}
+Updated: {updated}
+Resolved: {resolutiondate}
+
+Attachments:
+{"".join(attachment_list_items)}
+
+Issue Links:
+{"".join(linked_issues_list_items)}
+Sub-Tasks:
+{"".join(subtasks_list_items)}
+
+Pull Requests:
+{"".join(pull_requests_list)}
+"""
+
+        def comment_author(author_name, author_dispname):
+            author_gh = account_map.get(author_name)
+            return f"{author_dispname} ({may_markup(author_gh)})" if author_gh else author_dispname
+        
+        comments = extract_comments(o)
+        comments_data = []
+        for (comment_author_name, comment_author_dispname, comment_body, comment_created, comment_updated) in comments:
+            data = {
+                "body": f"""{convert_text(comment_body, att_replace_map)}
+
+Author: {comment_author(comment_author_name, comment_author_dispname)}
+Created: {comment_created}
+Updated: {comment_updated}
+"""
+            }
+            if comment_created:
+                data["created_at"] = jira_timestamp_to_github_timestamp(comment_created)
+            comments_data.append(data)
+
+        labels = []
+        if issue_type and ISSUE_TYPE_TO_LABEL_MAP.get(issue_type):
+            labels.append(ISSUE_TYPE_TO_LABEL_MAP.get(issue_type))
+        # milestone?
+        for v in fix_versions:
+            if v:
+                labels.append(f"fixVersion:{v}")
+        for v in versions:
+            if v:
+                labels.append(f"affectsVersion:{v}")
+        for c in components:
+            if c.startswith("core"):
+                labels.append(f"component:module/{c}")
+            elif c in COMPONENT_TO_LABEL_MAP:
+                labels.append(COMPONENT_TO_LABEL_MAP.get(c))
+
+        data = {
+            "issue": {
+                "title": make_github_title(summary, jira_id),
+                "body": body,
+                "closed": status in ["Closed", "Resolved"],
+                "labels": labels,
+            },
+            "comments": comments_data
+        }
+        if created:
+            data["issue"]["created_at"] = jira_timestamp_to_github_timestamp(created)
+        if updated:
+            data["issue"]["updated_at"] = jira_timestamp_to_github_timestamp(updated)
+        if resolutiondate:
+            data["issue"]["closed_at"] = jira_timestamp_to_github_timestamp(resolutiondate)
+
+        data_file = github_data_file(output_dir, num)
+        with open(data_file, "w") as fp:
+            json.dump(data, fp, indent=2)
+
+    logger.debug(f"GitHub issue data created: {data_file}")
+    return True
+
+
+if __name__ == "__main__":
+    github_att_repo = os.getenv("GITHUB_ATT_REPO")
+    if not github_att_repo:
+        print("Please set your GitHub attachment repo to GITHUB_ATT_REPO environment variable.")
+        sys.exit(1)
+    github_att_branch = os.getenv("GITHUB_ATT_BRANCH")
+    if not github_att_repo:
+        print("Please set your GitHub attachment branch to GITHUB_ATT_BRANCH environment variable.")
+        sys.exit(1)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--issues', type=int, required=False, nargs='*', help='Jira issue number list to be downloaded')
+    parser.add_argument('--min', type=int, dest='min', required=False, default=1, help='Minimum Jira issue number to be converted')
+    parser.add_argument('--max', type=int, dest='max', required=False, help='Maximum Jira issue number to be converted')
+    args = parser.parse_args()
+
+    dump_dir = Path(__file__).resolve().parent.parent.joinpath(JIRA_DUMP_DIRNAME)
+    if not dump_dir.exists():
+        logger.error(f"Jira dump dir not exists: {dump_dir}")
+        sys.exit(1)
+
+    mappings_dir = Path(__file__).resolve().parent.parent.joinpath(MAPPINGS_DATA_DIRNAME)
+    account_mapping_file = mappings_dir.joinpath(ACCOUNT_MAPPING_FILENAME)
+
+    output_dir = Path(__file__).resolve().parent.parent.joinpath(GITHUB_IMPORT_DATA_DIRNAME)
+    if not output_dir.exists():
+        output_dir.mkdir()
+    assert output_dir.exists()
+
+    account_map = read_account_map(account_mapping_file) if account_mapping_file else {}
+
+    issues = []
+    if args.issues:
+        issues = args.issues
+    else:
+        if args.max:
+            issues.extend(list(range(args.min, args.max + 1)))
+        else:
+            issues.append(args.min)
+
+    logger.info(f"Converting Jira issues to GitHub issues in {output_dir}")
+    for num in issues:
+        convert_issue(num, dump_dir, output_dir, account_map, github_att_repo, github_att_branch)
+    
+    logger.info("Done.")
+
diff --git a/migration/src/jira_util.py b/migration/src/jira_util.py
new file mode 100644
index 0000000..8cbc86b
--- /dev/null
+++ b/migration/src/jira_util.py
@@ -0,0 +1,198 @@
+import re
+from dataclasses import dataclass
+from collections import defaultdict
+from typing import Optional
+
+import jira2markdown
+
+
+@dataclass
+class Attachment(object):
+    filename: str
+    created: str
+    content: str
+    mime_type: str
+
+
+def extract_summary(o: dict) -> str:
+    return o.get("fields").get("summary", "")
+
+
+def extract_description(o: dict) -> str:
+    description = o.get("fields").get("description", "")
+    return description if description else ""
+
+
+def extract_status(o: dict) -> str:
+    status = o.get("fields").get("status")
+    return status.get("name", "") if status else ""
+
+
+def extract_issue_type(o: dict) -> str:
+    issuetype = o.get("fields").get("issuetype")
+    return issuetype.get("name", "") if issuetype else ""
+
+
+def extract_reporter(o: dict) -> tuple[str, str]:
+    reporter = o.get("fields").get("reporter")
+    name = reporter.get("name", "") if reporter else ""
+    disp_name = reporter.get("displayName", "") if reporter else ""
+    return (name, disp_name)
+
+
+def extract_assignee(o: dict) -> tuple[str, str]:
+    assignee = o.get("fields").get("assignee")
+    name = assignee.get("name", "") if assignee else ""
+    disp_name = assignee.get("displayName", "") if assignee else ""
+    return (name, disp_name)
+
+
+def extract_created(o: dict) -> str:
+    return o.get("fields").get("created", "")
+
+
+def extract_updated(o: dict) -> str:
+    return o.get("fields").get("updated", "")
+
+
+def extract_resolutiondate(o: dict) -> str:
+    return o.get("fields").get("resolutiondate", "")
+
+
+def extract_fixversions(o: dict) -> list[str]:
+    return [x.get("name", "") for x in o.get("fields").get("fixVersions", [])]
+
+
+def extract_versions(o: dict) -> list[str]:
+    return [x.get("name", "") for x in o.get("fields").get("versions", [])]
+
+
+def extract_components(o: dict) -> list[str]:
+    return [x.get("name", "") for x in o.get("fields").get("components", [])]
+
+
+def extract_attachments(o: dict) -> list[tuple[str, int]]:
+    attachments = o.get("fields").get("attachment")
+    if not attachments:
+        return []
+    files = {}
+    counts = defaultdict(int)
+    for a in attachments:
+        filename = a.get("filename")
+        created = a.get("created")
+        content = a.get("content")
+        mime_type = a.get("mimeType")
+        if not (filename and created and content and mime_type):
+            continue
+        if filename not in files or created > files[filename].created:
+            files[filename] = Attachment(filename=filename, created=created, content=content, mime_type=mime_type)
+        counts[filename] += 1
+    result = []
+    for name in files.keys():
+        result.append((name, counts[name]))
+    return result
+
+
+def extract_issue_links(o: dict) -> list[str]:
+    issue_links = o.get("fields").get("issuelinks", [])
+    if not issue_links:
+        return []
+
+    res = []
+    for link in issue_links:
+        key = link.get("outwardIssue", {}).get("key")
+        if key:
+            res.append(key)
+        key = link.get("inwardIssue", {}).get("key")
+        if key:
+            res.append(key)
+    return res
+
+
+def extract_subtasks(o: dict) -> list[str]:
+    return [x.get("key", "") for x in o.get("fields").get("subtasks", [])]
+
+
+def extract_comments(o: dict) -> list[str, str, str, str, str]:
+    comments = o.get("fields").get("comment", {}).get("comments", [])
+    if not comments:
+        return []
+    res = []
+    for c in comments:
+        author = c.get("author")
+        name = author.get("name", "") if author else ""
+        disp_name = author.get("displayName", "") if author else ""
+        body = c.get("body", "")
+        created = c.get("created", "")
+        updated = c.get("updated", "")
+        res.append((name, disp_name, body, created, updated))
+    return res
+
+
+def extract_pull_requests(o: dict) -> list[str]:
+    worklogs = o.get("fields").get("worklog", {}).get("worklogs", [])
+    if not worklogs:
+        return []
+    res = []
+    for wl in worklogs:
+        if wl.get("author").get("name", "") != "githubbot":
+            continue
+        comment: str = wl.get("comment", "")
+        if not comment:
+            continue
+        if "opened a new pull request" not in comment and not "opened a pull request" in comment:
+            continue
+        comment = comment.replace('\n', ' ')
+        # detect pull request url
+        matches = re.match(r".*(https://github\.com/apache/lucene/pull/\d+)", comment)
+        if matches:
+            res.append(matches.group(1))
+        # detect pull request url in old lucene-solr repo
+        matches = re.match(r".*(https://github\.com/apache/lucene-solr/pull/\d+)", comment)
+        if matches:
+            res.append(matches.group(1))     
+    return res
+
+
+REGEX_JIRA_KEY = re.compile(r"[^/]LUCENE-\d+")
+REGEX_MENTION = re.compile(r"@\w+")
+REGEX_LINK = re.compile(r"\[([^\]]+)\]\(([^\)]+)\)")
+
+
+def convert_text(text: str, att_replace_map: dict[str, str] = {}) -> str:
+    """Convert Jira markup to Markdown
+    """
+    def repl_att(m: re.Match):
+        res = m.group(0)
+        for src, repl in att_replace_map.items():
+            if m.group(2) == src:
+                res = f"[{m.group(1)}]({repl})"
+        return res
+
+    text = jira2markdown.convert(text)
+
+    # markup @ mentions with ``
+    mentions = re.findall(REGEX_MENTION, text)
+    if mentions:
+        mentions = set(mentions)
+        for m in mentions:
+            with_backtick = f"`{m}`"
+            text = text.replace(m, with_backtick)
+    
+    text = re.sub(REGEX_LINK, repl_att, text)
+
+    return text
+
+
+def embed_gh_issue_link(text: str, issue_id_map: dict[str, str]) -> str:
+    """Embed GitHub issue number
+    """
+    jira_keys = [m[1:] for m in re.findall(REGEX_JIRA_KEY, text)]
+    if jira_keys:
+        jira_keys = set(jira_keys)
+        for key in jira_keys:
+            gh_number = issue_id_map.get(key)
+            if gh_number:
+                new_key = f"{key} (#{gh_number})"
+                text = text.replace(key, new_key)
+    return text
diff --git a/migration/src/py.typed b/migration/src/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/migration/src/py.typed
diff --git a/migration/src/update_issue_links.py b/migration/src/update_issue_links.py
new file mode 100644
index 0000000..4625480
--- /dev/null
+++ b/migration/src/update_issue_links.py
@@ -0,0 +1,94 @@
+#
+# Update GitHub issues/comments to map Jira key to GitHub issue number
+# Usage:
+#   python src/update_issue_links.py --issues <issue number list>
+#   python src/update_issue_links.py
+#
+
+import argparse
+from pathlib import Path
+import sys
+import os
+
+from common import LOG_DIRNAME, MAPPINGS_DATA_DIRNAME, ISSUE_MAPPING_FILENAME, MaxRetryLimitExceedException, logging_setup, read_issue_id_map, retry_upto
+from github_issues_util import *
+from jira_util import embed_gh_issue_link
+
+
+log_dir = Path(__file__).resolve().parent.parent.joinpath(LOG_DIRNAME)
+logger = logging_setup(log_dir, "update_issue_links")
+
+
+@retry_upto(3, 1.0, logger)
+def update_issue_link_in_issue_body(issue_number: int, issue_id_map: dict[str, str], token: str, repo: str):
+    body = get_issue_body(token, repo, issue_number, logger)
+    if body:
+        updated_body = embed_gh_issue_link(body, issue_id_map)
+        if updated_body == body:
+            logger.debug(f"Issue {issue_number} does not contain any cross-issue links; nothing to do.")
+            return
+        if update_issue_body(token, repo, issue_number, updated_body, logger):
+            logger.debug(f"Issue {issue_number} was successfully updated.")
+            
+
+
+@retry_upto(3, 1.0, logger)
+def update_issue_link_in_comments(issue_number: int, issue_id_map: dict[str, str], token: str, repo: str):
+    comments = get_issue_comments(token, repo, issue_number, logger)
+    if not comments:
+        return
+    logger.debug(f"# comments in issue {issue_number} = {len(comments)}")
+    for comment in comments:
+        id = comment.id
+        body = comment.body
+        updated_body = embed_gh_issue_link(body, issue_id_map)
+        if updated_body == body:
+            logger.debug(f"Comment {id} does not contain any cross-issue links; nothing to do.")
+            continue
+        if update_comment_body(token, repo, id, updated_body, logger):
+            logger.debug(f"Comment {id} was successfully updated.")
+
+
+if __name__ == "__main__":
+    github_token = os.getenv("GITHUB_PAT")
+    if not github_token:
+        print("Please set your GitHub token to GITHUB_PAT environment variable.")
+        sys.exit(1)
+    github_repo = os.getenv("GITHUB_REPO")
+    if not github_repo:
+        print("Please set GitHub repo location to GITHUB_REPO environment varialbe.")
+        sys.exit(1)
+
+    check_authentication(github_token)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--issues', type=int, required=False, nargs='*', help='Jira issue number list to be downloaded')
+    args = parser.parse_args()
+    
+    mapping_data_dir = Path(__file__).resolve().parent.parent.joinpath(MAPPINGS_DATA_DIRNAME)
+    issue_mapping_file = mapping_data_dir.joinpath(ISSUE_MAPPING_FILENAME)
+    if not issue_mapping_file.exists():
+        logger.error(f"Jira-GitHub issue id mapping file not found. {issue_mapping_file}")
+        sys.exit(1)
+    issue_id_map = read_issue_id_map(issue_mapping_file)
+    
+    issues = []
+    if args.issues:
+        issues = args.issues
+    else:
+        issues = list(issue_id_map.values())
+    
+    logger.info(f"Updating GitHub issues")
+    for num in issues:
+        try:
+            update_issue_link_in_issue_body(num, issue_id_map, github_token, github_repo)
+        except MaxRetryLimitExceedException:
+            logger.error(f"Failed to update issue body. Skipped issue {num}")
+            continue
+        try:
+            update_issue_link_in_comments(num, issue_id_map, github_token, github_repo)
+        except MaxRetryLimitExceedException:
+            logger.error(f"Failed to update issue comments. Skipped issue {num}")
+            continue
+
+    logger.info("Done.")
\ No newline at end of file