/* * This file is part of muCommander, http://www.mucommander.com * Copyright (C) 2002-2016 Maxence Bernard * * muCommander is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * muCommander is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.mucommander.ui.main.table; import java.util.Date; import javax.swing.table.AbstractTableModel; import com.mucommander.commons.file.AbstractFile; import com.mucommander.commons.file.CachedFile; import com.mucommander.commons.file.filter.FileFilter; import com.mucommander.commons.file.util.FileComparator; import com.mucommander.commons.file.util.FileSet; import com.mucommander.conf.MuConfigurations; import com.mucommander.conf.MuPreference; import com.mucommander.conf.MuPreferences; import com.mucommander.text.CustomDateFormat; import com.mucommander.text.SizeFormat; /** * This class maps table cells onto file attributes. * * @author Maxence Bernard */ public class FileTableModel extends AbstractTableModel { /** The current folder */ private AbstractFile currentFolder; /** Date of the current folder when it was changed */ private long currentFolderDateSnapshot; /** The current folder's parent folder, may be null */ private AbstractFile parent; /** Cached file instances */ private AbstractFile cachedFiles[]; /** Index array */ private int fileArrayIndex[]; /** Cell values cache */ private Object cellValuesCache[][]; /** Marked rows array */ private boolean rowMarked[]; /** Combined size of files currently marked */ private long markedTotalSize; /** Number of files currently marked */ private int nbRowsMarked; /** Contains sort-related variables */ private SortInfo sortInfo; /** True if the name column is temporarily editable */ private boolean nameColumnEditable; /** SizeFormat format used to create the size column's string */ private static int sizeFormat; /** String used as size information for directories */ public final static String DIRECTORY_SIZE_STRING = "<DIR>"; static { // Initialize the size column format based on the configuration setSizeFormat(MuConfigurations.getPreferences().getVariable(MuPreference.DISPLAY_COMPACT_FILE_SIZE, MuPreferences.DEFAULT_DISPLAY_COMPACT_FILE_SIZE)); } /** * Sets the SizeFormat format used to create the size column's string. * * @param compactSize true to use a compact size format, false for full size in bytes */ static void setSizeFormat(boolean compactSize) { if(compactSize) sizeFormat = SizeFormat.DIGITS_MEDIUM | SizeFormat.UNIT_SHORT | SizeFormat.ROUND_TO_KB; else sizeFormat = SizeFormat.DIGITS_FULL | SizeFormat.UNIT_NONE; sizeFormat |= SizeFormat.INCLUDE_SPACE; } /** * Creates a new FileTableModel, without any initial current folder. */ public FileTableModel() { // Init arrays to avoid NullPointerExceptions until setCurrentFolder() gets called for the first time cachedFiles = new AbstractFile[0]; fileArrayIndex = new int[0]; cellValuesCache = new Object[0][Column.values().length-1]; rowMarked = new boolean[0]; } /** * Sets the {@link com.mucommander.ui.main.table.SortInfo} instance that describes how the associated table is * sorted. * * @param sortInfo SortInfo instance that describes how the associated table is sorted */ void setSortInfo(SortInfo sortInfo) { this.sortInfo = sortInfo; } /** * Returns the current folder, i.e. the last folder set using {@link #setCurrentFolder(com.mucommander.commons.file.AbstractFile, com.mucommander.commons.file.AbstractFile[])}. * * @return the current folder */ public synchronized AbstractFile getCurrentFolder() { return currentFolder; } /** * Returns the date of the current folder, when it was set using {@link #setCurrentFolder(com.mucommander.commons.file.AbstractFile, com.mucommander.commons.file.AbstractFile[])}. * In other words, the returned date is a snapshot of the current folder's date which is never updated. * * @return Returns the date of the current folder, when it was set using #setCurrentFolder(Abstract, Abstract[]) */ public synchronized long getCurrentFolderDateSnapshot() { return currentFolderDateSnapshot; } /** * Returns <code>true</code> if the current folder has a parent. * * @return <code>true</code> if the current folder has a parent */ public synchronized boolean hasParentFolder() { return parent!=null; } /** * Returns the current folder's parent if there is one, <code>null</code> otherwise. * * @return the current folder's parent if there is one, <code>null</code> otherwise */ public synchronized AbstractFile getParentFolder() { return parent; } /** * Returns the index of the first row that can be marked/unmarked : <code>1</code> if the current folder has a * parent folder, <code>0</code> otherwise (parent folder row '..' cannot be marked). * * @return the index of the first row that can be marked/unmarked */ public int getFirstMarkableRow() { return parent==null?0:1; } /** * Sets the current folder and its children. * * @param folder the current folder * @param children the current folder's children */ synchronized void setCurrentFolder(AbstractFile folder, AbstractFile children[]) { int nbFiles = children.length; this.currentFolder = (folder instanceof CachedFile)?folder:new CachedFile(folder, true); this.parent = currentFolder.getParent(); // Note: the returned parent is a CachedFile instance if(parent!=null) { // Pre-fetch the attributes that are used by the table renderer and some actions. prefetchCachedFileAttributes(parent); } // Initialize file indexes and create CachedFile instances to speed up table display and navigation this.cachedFiles = children; this.fileArrayIndex = new int[nbFiles]; AbstractFile file; for(int i=0; i<nbFiles; i++) { file = new CachedFile(children[i], true); // Pre-fetch the attributes that are used by the table renderer and some actions. prefetchCachedFileAttributes(file); cachedFiles[i] = file; fileArrayIndex[i] = i; } // Reset marked files int nbRows = getRowCount(); this.rowMarked = new boolean[nbRows]; this.markedTotalSize = 0; this.nbRowsMarked = 0; // Init and fill cell cache to speed up table even more this.cellValuesCache = new Object[nbRows][Column.values().length-1]; fillCellCache(); } /** * Pre-fetch the attributes that are used by the table renderer and some actions from the given CachedFile. * By doing so, the attributes will be available when the associated getters are called and thus the methods won't * be I/O bound and will not lock. * * @param cachedFile a CachedFile instance from which to pre-fetch attributes */ private static void prefetchCachedFileAttributes(AbstractFile cachedFile) { cachedFile.isDirectory(); cachedFile.isBrowsable(); cachedFile.isHidden(); // Pre-fetch isSymlink attribute and if the file is a symlink, pre-fetch the canonical file and its attributes if(cachedFile.isSymlink()) { AbstractFile canonicalFile = cachedFile.getCanonicalFile(); if(canonicalFile!=cachedFile) // Cheap test to prevent infinite recursion on bogus file implementations prefetchCachedFileAttributes(canonicalFile); } } /** * Retrieves all cell values and stores them in an array for fast access. */ synchronized void fillCellCache() { int len = cellValuesCache.length; if(len==0) return; // Special '..' file if(parent!=null) { cellValuesCache[0][Column.NAME.ordinal()-1] = ".."; cellValuesCache[0][Column.SIZE.ordinal()-1] = DIRECTORY_SIZE_STRING; currentFolderDateSnapshot = currentFolder.getDate(); cellValuesCache[0][Column.DATE.ordinal()-1] = CustomDateFormat.format(new Date(currentFolderDateSnapshot)); // Don't display parent's permissions as they can have a different format from the folder contents // (e.g. for archives) and this looks weird cellValuesCache[0][Column.PERMISSIONS.ordinal()-1] = ""; cellValuesCache[0][Column.OWNER.ordinal()-1] = ""; cellValuesCache[0][Column.GROUP.ordinal()-1] = ""; } AbstractFile file; int fileIndex = 0; for(int i=parent==null?0:1; i<len; i++) { file = getCachedFileAtRow(i); int cellIndex = fileArrayIndex[fileIndex]+(parent==null?0:1); cellValuesCache[cellIndex][Column.NAME.ordinal()-1] = file.getName(); cellValuesCache[cellIndex][Column.SIZE.ordinal()-1] = file.isDirectory()?DIRECTORY_SIZE_STRING:SizeFormat.format(file.getSize(), sizeFormat); cellValuesCache[cellIndex][Column.DATE.ordinal()-1] = CustomDateFormat.format(new Date(file.getDate())); cellValuesCache[cellIndex][Column.PERMISSIONS.ordinal()-1] = file.getPermissionsString(); cellValuesCache[cellIndex][Column.OWNER.ordinal()-1] = file.getOwner(); cellValuesCache[cellIndex][Column.GROUP.ordinal()-1] = file.getGroup(); fileIndex++; } } /** * Returns a CachedFile instance of the file located at the given row index. * This method can return the parent folder file ('..') if a parent exists and rowIndex is 0. * * <p>Returns <code>null</code> if rowIndex is lower than 0 or is greater than or equals * {@link #getRowCount() getRowCount()}.</p> * * @param rowIndex a row index, comprised between 0 and #getRowCount() * @return a CachedFile instance of the file located at the given row index */ public synchronized AbstractFile getCachedFileAtRow(int rowIndex) { if(rowIndex==0 && parent!=null) return parent; if(parent!=null) rowIndex--; // Need to check that row index is not larger than actual number of rows // because if table has just been changed (rows have been removed), // JTable may have an old row count value and may try to repaint rows that are out of bounds. if(rowIndex>=0 && rowIndex<fileArrayIndex.length) return cachedFiles[fileArrayIndex[rowIndex]]; return null; } /** * Returns the current folder's children. The returned array contains {@link CachedFile} instances, where * most attributes have already been fetched and cached. * * @return the current folder's children, as an array of CachedFile instances * @see #getFiles() */ public synchronized AbstractFile[] getCachedFiles() { // Clone the array to make sure it can't be modified outside of this class AbstractFile[] cachedFilesCopy = new AbstractFile[cachedFiles.length]; System.arraycopy(cachedFiles, 0, cachedFilesCopy, 0, cachedFiles.length); return cachedFilesCopy; } /** * Returns the file located at the given row index. * This method can return the parent folder file ('..') if a parent exists and rowIndex is 0. * * <p>Returns <code>null</code> if rowIndex is lower than 0 or is greater than or equals * {@link #getRowCount() getRowCount()}.</p> * * @param rowIndex a row index, comprised between 0 and #getRowCount() * @return the file located at the given row index */ public synchronized AbstractFile getFileAtRow(int rowIndex) { AbstractFile file = getCachedFileAtRow(rowIndex); if(file==null) return null; else if(file instanceof CachedFile) return ((CachedFile)file).getProxiedFile(); else return file; } /** * Returns the current folder's children. The returned array contains {@link AbstractFile} instances, and not * CachedFile instances contrary to {@link #getCachedFiles()}. * * @return the current folder's children * @see #getCachedFiles() */ public synchronized AbstractFile[] getFiles() { int nbFiles = cachedFiles.length; AbstractFile[] files = new AbstractFile[nbFiles]; for(int i=0; i<nbFiles; i++) files[i] = cachedFiles[i]==null?null:((CachedFile)cachedFiles[i]).getProxiedFile(); return files; } /** * Returns the index of the row where the given file is located, <code>-1<code> if the file is not in the * current folder. * * @param file the file for which to find the row index * @return the index of the row where the given file is located, <code>-1<code> if the file is not in the * current folder */ public synchronized int getFileRow(AbstractFile file) { // Handle parent folder file if(parent!=null && file.equals(parent)) return 0; // Use dichotomic binary search rather than a dumb linear search since file array is sorted, // complexity is reduced to O(log n) instead of O(n^2) int left = parent==null?0:1; int right = getRowCount()-1; int mid; AbstractFile midFile; FileComparator fc = getFileComparator(sortInfo); while(left<=right) { mid = (right-left)/2 + left; midFile = getCachedFileAtRow(mid); if(midFile.equals(file)) return mid; if(fc.compare(file, midFile)<0) right = mid-1; else left = mid+1; } return -1; } /** * Returns the file located at the given index, not including the parent file. * Returns <code>null</code> if fileIndex is lower than 0 or is greater than or equals {@link #getFileCount() getFileCount()}. * * @param fileIndex index of a file, comprised between 0 and #getFileCount() * @return the file located at the given index, not including the parent file */ public synchronized AbstractFile getFileAt(int fileIndex) { // Need to check that row index is not larger than actual number of rows // because if table has just been changed (rows have been removed), // JTable may have an old row count value and may try to repaint rows that are out of bounds. if(fileIndex>=0 && fileIndex<fileArrayIndex.length) { return ((CachedFile)cachedFiles[fileArrayIndex[fileIndex]]).getProxiedFile(); } return null; } /** * Returns the actual number of files the current folder contains, excluding the parent '..' file (if any). * * @return the actual number of files the current folder contains, excluding the parent '..' file (if any) */ public synchronized int getFileCount() { return cachedFiles.length; } /** * Returns <code>true</code> if the given row is marked (/!\ not selected). If the specified row corresponds to the * special '..' parent file, <code>false</code> is always returned. * * @param row index of a row to test * @return <code>true</code> if the given row is marked */ public synchronized boolean isRowMarked(int row) { if(row==0 && parent!=null) return false; return row<getRowCount() && rowMarked[fileArrayIndex[parent==null?row:row-1]]; } /** * Marks/Unmarks the given row. If the specified row corresponds to the special '..' parent file, the row won't * be marked. * * @param row the row to mark/unmark * @param marked <code>true</code> to mark the row, <code>false</code> to unmark it */ public synchronized void setRowMarked(int row, boolean marked) { if(row==0 && parent!=null) return; int rowIndex = parent==null?row:row-1; // Return if the row is already marked/unmarked if((marked && rowMarked[fileArrayIndex[rowIndex]]) || (!marked && !rowMarked[fileArrayIndex[rowIndex]])) return; AbstractFile file = getCachedFileAtRow(row); // Do not call getSize() on directories, it's unnecessary and the value is most likely not cached by CachedFile yet long fileSize = file.isDirectory()?0:file.getSize(); // Update : // - Combined size of marked files // - marked files FileSet if(marked) { // File size can equal -1 if not available, do not count that in total if(fileSize>0) markedTotalSize += fileSize; nbRowsMarked++; } else { // File size can equal -1 if not available, do not count that in total if(fileSize>0) markedTotalSize -= fileSize; nbRowsMarked--; } rowMarked[fileArrayIndex[rowIndex]] = marked; } /** * Marks/unmarks the given row range, delimited by the provided start row index and end row index (inclusive). * End row may be less, greater or equal to the start row. * * @param startRow index of the first row to mark/unmark * @param endRow index of the last row to mark/ummark, startRow may be less or greater than startRow * @param marked if true, all the rows within the range will be marked, unmarked otherwise */ public void setRangeMarked(int startRow, int endRow, boolean marked) { if(endRow >= startRow) { for(int i= startRow; i<= endRow; i++) setRowMarked(i, marked); } else { for(int i= startRow; i>= endRow; i--) setRowMarked(i, marked); } } /** * Marks/Unmarks the given file. * * @param file the file to mark/unmark * @param marked <code>true</code> to mark the row, <code>false</code> to unmark it. */ public synchronized void setFileMarked(AbstractFile file, boolean marked) { int row = getFileRow(file); if(row!=-1) setRowMarked(row, marked); } /** * Marks/unmarks the files that match the given {@link FileFilter}. * * @param filter the FileFilter to match the files against * @param marked if true, matching files will be marked, if false, they will be unmarked */ public synchronized void setFilesMarked(FileFilter filter, boolean marked) { int nbFiles = getRowCount(); for(int i=parent==null?0:1; i<nbFiles; i++) { if(filter.match(getCachedFileAtRow(i))) setRowMarked(i, marked); } } /** * Returns a {@link com.mucommander.commons.file.util.FileSet FileSet} with all currently marked files. * <p> * The returned <code>FileSet</code> is a freshly created instance, so it can be safely modified. & However, it won't be kept current : the returned FileSet is just a snapshot * which might not reflect the current marked files state after this method has returned and additional * files have been marked/unmarked. * </p> * * @return a FileSet containing all the files that are currently marked */ public synchronized FileSet getMarkedFiles() { FileSet markedFiles = new FileSet(currentFolder, nbRowsMarked); int nbRows = getRowCount(); if(parent==null) { for(int i=0; i<nbRows; i++) { if(rowMarked[fileArrayIndex[i]]) markedFiles.add(getFileAtRow(i)); } } else { for(int i=1, iMinusOne=0; i<nbRows; i++) { if(rowMarked[fileArrayIndex[iMinusOne]]) markedFiles.add(getFileAtRow(i)); iMinusOne = i; } } return markedFiles; } /** * Returns the number of marked files. This number is pre-calculated so calling this method is much faster than * retrieving the list of marked files and counting them. * * @return the number of marked files */ public int getNbMarkedFiles() { return nbRowsMarked; } /** * Returns the combined size of marked files. This number is pre-calculated so calling this method is much faster * than retrieving the list of marked files and calculating their combined size. * * @return the combined size of marked files */ public long getTotalMarkedSize() { return markedTotalSize; } /** * Makes the name column temporarily editable. This method should only be called by FileTable. * * @param editable <code>true</code> to make the name column editable, false to prevent it from being edited */ void setNameColumnEditable(boolean editable) { this.nameColumnEditable = editable; } ////////////////// // Sort methods // ////////////////// private static FileComparator getFileComparator(SortInfo sortInfo) { return new FileComparator(sortInfo.getCriterion().getFileComparatorCriterion(), sortInfo.getAscendingOrder(), sortInfo.getFoldersFirst()); } /** * Sorts rows by the current criterion, ascending/descending order and 'folders first' value. */ synchronized void sortRows() { sort(getFileComparator(sortInfo), 0, fileArrayIndex.length-1); } /** * Quick sort implementation, based on James Gosling's implementation. */ private void sort(FileComparator fc, int lo0, int hi0) { int lo = lo0; int hi = hi0; int temp; if (lo >= hi) { return; } else if( lo == hi - 1 ) { // sort a two element list by swapping if necessary if (fc.compare(cachedFiles[fileArrayIndex[lo]],cachedFiles[fileArrayIndex[hi]])>0) { temp = fileArrayIndex[lo]; fileArrayIndex[lo] = fileArrayIndex[hi]; fileArrayIndex[hi] = temp; } return; } // Pick a pivot and move it out of the way int pivotIndex = fileArrayIndex[(lo + hi) / 2]; fileArrayIndex[(lo + hi) / 2] = fileArrayIndex[hi]; fileArrayIndex[hi] = pivotIndex; AbstractFile pivot = cachedFiles[pivotIndex]; while( lo < hi ) { // Search forward from files[lo] until an element is found that // is greater than the pivot or lo >= hi while (fc.compare(cachedFiles[fileArrayIndex[lo]], pivot)<=0 && lo < hi) { lo++; } // Search backward from files[hi] until element is found that // is less than the pivot, or lo >= hi while (fc.compare(pivot, cachedFiles[fileArrayIndex[hi]])<=0 && lo < hi ) { hi--; } // Swap elements files[lo] and files[hi] if( lo < hi ) { temp = fileArrayIndex[lo]; fileArrayIndex[lo] = fileArrayIndex[hi]; fileArrayIndex[hi] = temp; } } // Put the median in the "center" of the list fileArrayIndex[hi0] = fileArrayIndex[hi]; fileArrayIndex[hi] = pivotIndex; // Recursive calls, elements files[lo0] to files[lo-1] are less than or // equal to pivot, elements files[hi+1] to files[hi0] are greater than // pivot. sort(fc, lo0, lo-1); sort(fc, hi+1, hi0); } ////////////////////////////////////////// // Overriden AbstractTableModel methods // ////////////////////////////////////////// public int getColumnCount() { return Column.values().length; // icon, name, size, date, permissions, owner, group } @Override public String getColumnName(int columnIndex) { return Column.valueOf(columnIndex).getLabel(); } /** * Returns the total number of rows, including the special parent folder file '..', if there is one. */ public synchronized int getRowCount() { return fileArrayIndex.length + (parent==null?0:1); } // public Object getValueAt(int rowIndex, int columnIndex) { public synchronized Object getValueAt(int rowIndex, int columnIndex) { // Need to check that row index is not larger than actual number of rows // because if table has just been changed (rows have been removed), // JTable may have an old row count value and may try to repaint rows that are out of bounds. if(rowIndex>=getRowCount()) { // Returning null will have JTable ignore this row return null; } // Icon/extension column, return a null value Column column = Column.valueOf(columnIndex); if(column==Column.EXTENSION) return null; // Decrement column index for cellValuesCache array columnIndex--; // Handle special '..' file if(rowIndex==0 && parent!=null) return cellValuesCache[0][columnIndex]; int fileIndex = parent==null?rowIndex:rowIndex-1; return cellValuesCache[fileArrayIndex[fileIndex]+(parent==null?0:1)][columnIndex]; } /** * Returns <code>true</code> if name column has temporarily be made editable by FileTable * and given row doesn't correspond to parent file '..', <code>false</code> otherwise. */ @Override public boolean isCellEditable(int rowIndex, int columnIndex) { // Name column can temporarily be made editable by FileTable // but parent file '..' name should never be editable if(Column.valueOf(columnIndex)==Column.NAME && (parent==null || rowIndex!=0)) return nameColumnEditable; return false; } }