/*
 *  Copyright 2019 Anyware Services
 *
 *  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.
 */

/**
 * This tree is an abstract tree panel for displaying a system files tree.
 * Creates your own tree class by inheriting this one and define at the following methods: 
 * #createTreeStore, 
 * #editNode (only if cfg-allowNodeEditing is not set to false)
 * #getMessageTargetConfiguration
 * #getMessageTargetIdForResource, 
 * #getMessageTargetIdForCollection,
 * #testTarget.
 * @private
 */
Ext.define('Ametys.file.AbstractFileExplorerTree', {
    extend: "Ext.tree.Panel",
    
    /**
     * @cfg {Boolean/String} [allowNodeEditing=true] Set to false to disable node editing. Then it is not necessary to implement #editNode.
     */
    
    /**
     * @cfg {Boolean/String} [allowDragAndDrop=true] Set to false to disable drag&drop.
     */
    
    /**
     * @cfg {Boolean/String} [enableOnFileSelected=false] Set to true to enable onFileSelected listener
     */
    
    /**
     * @cfg {Boolean/String} [allowFiltering=false] Set to true to display the filter toolbar
     */
    allowFiltering: false,
        
    statics: {
        /**
         * @property {String} TYPE_RESOURCE Type for resource file.
         */
        TYPE_RESOURCE: 'resource',
        /**
         * @property {String} TYPE_COLLECTION Type for resource collection
         */
        TYPE_COLLECTION: 'collection',
        
        /**
         * Get the extension of a file
         * @param {String} name The file name
         * @return {String} The extension
         */
        getFileExtension: function(name)
        {
            var extension = "unknown";
            var index = name.lastIndexOf('.');
            if (index > 0)
            {
                extension = name.substring(index + 1).toLowerCase();
            }
            return extension;
        },
        
        /**
         * Get the icon in small format (16x16 pixels) of a file
         * @param {String} name The file name
         * @return {String} The small icon
         */
        getFileIconGlyph: function(name)
        {
            var extension = Ametys.file.AbstractFileExplorerTree.getFileExtension(name);
            switch (extension)
            {
	            case "css":
	                return "ametysicon-file-extension-css";
	            case "csv":
	                return "ametysicon-file-extension-csv";
	            case "doc":
	                return "ametysicon-file-extension-doc";
	            case "docx":
	                return "ametysicon-file-extension-docx";
	            case "html":
	            case "htm":
	            case "xhtml":
	                return "ametysicon-file-extension-html";
	            case "png":
	            case "jpg":
	            case "jpeg":
	            case "bmp":
	            case "gif":
	                return "ametysicon-image2";
	            case "pdf":
	                return "ametysicon-file-extension-pdf";
	            case "ppt":
	                return "ametysicon-file-extension-ppt";
	            case "pptx":
	                return "ametysicon-file-extension-pptx";
	            case "txt":
	                return "ametysicon-file-extension-txt";
	            case "avi":
	            case "mov":
	            case "swf":
	            case "wmv":
	            case "flv":
	            case "mpeg":
	            case "mpg":
	            case "mp4":
	            case "ogv":
	            case "ogg":
	            case "mkv":
	            case "webm":
	                return "ametysicon-movie16";
	            case "xml":
	                return "ametysicon-file-extension-xml";
	            case "xls":
	                return "ametysicon-file-extension-xls";
	            case "xlsx":
	                return "ametysicon-file-extension-xlsx";
	            case "zip":
	                return "ametysicon-file-extension-zip";
	            case "rar":
	                return "ametysicon-file-extension-rar";
	            case "odp":
	                return "ametysicon-file-extension-odp";
	            case "odt":
	                return "ametysicon-file-extension-odt";
	            case "jar":
	                return "ametysicon-file-extension-jar";
	            case "pps":
	                return "ametysicon-file-extension-pps";
	            case "psd":
	                return "ametysicon-file-extension-psd";
	            case "tar":
	            case "tgz":
	                return "ametysicon-file-extension-tar";
	            case "mp3":
	            case "wav":
	            case "oga":
	            case "mod":
	            case "mid":
	                return "ametysicon-file-extension-generic-music";
	            default:
	                return "ametysicon-file-extension-generic-unknown";
	                break;
            }
        }
    },
   
    initComponent: function()
    {
        this.store = this.createTreeStore();
        this.store.on('beforeload', this._onBeforeLoad, this);
        
        this.callParent();
    },
    
    constructor: function(config) 
    {
        Ext.applyIf(config, {
            scrollable:true,
            animate:true,
            cls: 'explorer-tree',
            
            enableColumnResize: false,
            hideHeaders: true,
            columns: [{
                xtype: 'treecolumn',
                dataIndex: 'text',
                flex: 1,
                editor: {
                    xtype: 'textfield',
                    allowBlank: false,
                    selectOnFocus: true
                }
            }]
        });
        
        if (config.allowNodeEditing !== "false" && config.allowNodeEditing !== false)
        {
            Ext.applyIf(config, {
                plugins: {
	                ptype: 'cellediting',
	                clicksToEdit: 2,
	                listeners: {
	                    'beforeedit' : Ext.bind(this._onBeforeEdit, this),
	                    'edit' : Ext.bind(this._onEdit, this)
	                }
	            }
            });
        }
        
        if (config.allowDragAndDrop !== "false" && config.allowDragAndDrop !== false)
        {
            Ext.applyIf(config, {
                viewConfig: {
	                plugins: {
	                    ptype: 'ametystreeviewdragdrop',
	                    containerScroll: true,
	                    appendOnly: false,
	                    sortOnDrop: false,
	                    expandDelay: 500,
	                    allowContainerDrops: false,
	                    setAmetysDragInfos: Ext.bind(this.getDragInfo, this),
	                    setAmetysDropZoneInfos: Ext.bind(this.getDropInfo, this)
	                }
	            }
            });
            
        }
        
        if (this.allowFiltering === "true" || this.allowFiltering === true)
        {
            this._configureFiltering(config);
        }
        
        this.callParent(arguments);
        
        this._resourceTooltipTpl = Ext.create('Ext.Template', [
                '<span style="white-space: nowrap"><u>{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TOOLTIP_DATE}}</u> : </span> <span style="white-space: nowrap">{lastModified}</span><br/>',
                '<u>{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TOOLTIP_SIZE}}</u> : <span style="white-space: nowrap">{size}</span><br/>'
        ]);
        
        this.on('itemmouseenter', this._createQtip, this);
        
        // Listening to some bus messages.
        Ametys.message.MessageBus.on(Ametys.message.Message.CREATED, this._onFileCreated, this);
        Ametys.message.MessageBus.on(Ametys.message.Message.MODIFIED, this._onFileModified, this);
        Ametys.message.MessageBus.on(Ametys.message.Message.DELETED, this._onFileDeleted, this);
        Ametys.message.MessageBus.on(Ametys.message.Message.MOVED, this._onFileMoved, this);

        if (config.enableOnFileSelected === "true" || config.enableOnFileSelected === true)
        {
            // FIXME the SELECTION_CHANGED listener can create infinite loop when there is at least two editors opened then closing a editor that have no focus
            // Infinite loop can also occur at startup when there more than one editor is opened.
             Ametys.message.MessageBus.on(Ametys.message.Message.SELECTION_CHANGED, this._onFileSelected, this);
        }
    },

    onDestroy: function()
    {
        Ametys.message.MessageBus.unAll(this);
        this.callParent(arguments);
    },
    
   /**
    * Creates a store for the system files structure
    * @return {Ext.data.TreeStore} The tree store managing the system files structure
    */
   createTreeStore: function()
   {
       throw new Error("The method #createTreeStore is not implemented in " + this.self.getName());
   },
   
   /**
     * This function reload the given node
     * @param {String} path The path of the node to reload
     * @param {Function} callback The callback function to call after reload. Can be null. Has the following parameters:
     * @param {Ext.data.Model} callback.node The refreshed node
     */
    refreshNode: function(path, callback)
    {
        var node;
        if (path == null)
        {
            var selection = this.getSelectionModel().getSelection();
            node = selection.length > 0 ? selection[0] : null;
        }
        else
        {
            node = path != '' ? this.getSelectionModel().getStore().findRecord('path', path) : this.getRootNode();
        }
        
        if (node != null && (node.get('type') == Ametys.file.AbstractFileExplorerTree.TYPE_COLLECTION || node.get('type') == 'root'))
        {
            node.set('expanded', true);
            if (Ext.isFunction(callback))
            {
                this.store.load({node: node, callback: function(records, op, success) { callback (node); }});
            }
            else
            {
                this.store.load({node: node});
            }
        }
    },
    
    /**
     * Select a node in the tree
     * @param {Ext.data.Model/String} node The node itself or its id
     */
    selectNode: function(node)
    {
        if (typeof node == 'string')
        {
            node = this.getStore().getNodeById(node);
        }
        
        this.getSelectionModel().select([node]);
    },
    
    /**
     * Expand the given node
     * @param {String} node The node to expand.
     * @param {Function} callback The callback function to call after reload. Can be null. Has the following parameters:
     * @param {Ext.data.Model} callback.node The expended node
     * @private
     */
    _expandNode: function(node, callback)
    {
        if (node == null)
        {
            var selection = this.getSelectionModel().getSelection();
            node = selection.length > 0 ? selection[0] : null;
        }
        
        if (node != null)
        {
            callback = callback ? Ext.bind(callback, null, [node]) : null;
            this.expandNode(node, false, callback);
        }
    },
   
    /**
     * @private
     * Appends the path parameter to store's Ajax requests.
     */
    _onBeforeLoad: function(store, operation, eOpts)
    {
        var path = operation.node.get("path");
        if (path)
        {
            //Passing 'path' as extra parameter during the 'node expand' Ajax call
            operation.setParams( Ext.apply(operation.getParams(), {
                path: path
            }));
        }
    },
    
    /**
     * This listener is called before cell editing is triggered.
     * Check the user can edit the node. Returns false if the editing should be canceled.
     * @param {Ext.grid.plugin.CellEditing} editor The cell editor
     * @param {Object} context An editing context event with the following properties:
     * @private
     */
    _onBeforeEdit: function(editor, context)
    {
        var node = this.getSelectionModel().getSelection()[0];
        this._currentEditNode = node;
        return !node.isRoot();
    },
    
    /**
     * This listener is called after a cell is edited.
     * @param {Ext.grid.plugin.CellEditing} editor The cell editor
     * @param {Object} context An editing context event with the following properties:
     * @param {Ext.grid.Panel}         context.grid The owning grid Panel.
     * @param {Ext.data.Model}         context.record The record being edited.
     * @param {String}                 context.field The name of the field being edited.
     * @param {Mixed}                  context.value The field's current value.
     * @param {HTMLElement}            context.row The grid row element.
     * @param {Ext.grid.column.Column} context.column The Column being edited.
     * @param {Number}                 context.rowIdx The index of the row being edited.
     * @param {Number}                 context.colIdx The index of the column being edited.
     * @private
     */
    _onEdit: function(editor, context)
    {       
        if (!this._currentEditNode || Ext.String.trim(context.value) == Ext.String.trim(context.originalValue))
        {
            return;
        }
        
        this._valueBeforeEdit = context.originalValue;
        this.editNode(this._currentEditNode, this._valueBeforeEdit, context.value);
    },
    
    /**
     * @protected
     * @template 
     * Do rename a resource (folder or file)
     * @param {Ext.data.Model} node The resource
     * @param {String} oldname The name before edit.
     * @param {String} newname the new name of resources
     */
    editNode: function(node, oldname, newname)
    {
        throw new Error("The method #editNode is not implemented in " + this.self.getName());
    },
    
    /**
     * Update the name/text of a node
     * @param {Ext.data.Model} node The node to edit. If null nothing will be done.
     * @param {String} name The new name to set
     */
    updateNodeName: function(node, name)
    {
        if (node != null)
        {
            node.beginEdit();
            node.set('text', name);
            node.set('name', name);
            node.endEdit();
            node.commit();
        }
    },
    
    /**
     * Get the message target configuration for given node
     * @param {Ext.data.Model} node The node.
     * @param {Boolean} busMessage True if it is for a bus message, false if it is for the drag and drop.
     */
    getMessageTargetConfiguration: function(node, busMessage)
    {
        throw new Error("The method #getMessageTargetConfiguration is not implemented in " + this.self.getName());
    },
    
    /**
     * Destroy and create the node tooltip when the mouse enters the node
     * @param {Ext.view.View} view The tree view
     * @param {Ext.data.Model} node The tree node
     * @param {HTMLElement} el The node's element
     */
    _createQtip: function(view, node, el)
    {
        if (node.get('type') != Ametys.file.AbstractFileExplorerTree.TYPE_RESOURCE)
        {
            return;
        }
        
        Ext.QuickTips.unregister(el);
        Ext.QuickTips.register(Ext.apply({target: el, id: el.id + '-tooltip'}, this._getTooltip(node)));
    },
    
    /**
     * Get the tooltip configuration
     * @param {Ext.data.Model} node The tree node
     * @returns {Object} The tooltip configuration. See Ametys.ui.fluent.Tooltip.
     * @private
     */
    _getTooltip: function(node)
    {
        var lastModified = Ext.util.Format.date(Ext.Date.parse(node.get('lastModified'), Ext.Date.patterns.ISO8601DateTime), "{{i18n PLUGINS_CORE_UI_RESOURCE_TOOLTIP_DATE_FORMAT}}");
        
        var text = this._resourceTooltipTpl.applyTemplate ({
            author: node.get('author'), 
            lastModified: lastModified, 
            size: Ext.util.Format.fileSize(node.get('size'))
        });
        
        var extension = Ametys.file.AbstractFileExplorerTree.getFileExtension(node.get('name'));
        var isImg = extension == 'jpg' || extension == 'jpeg' || extension == 'gif' || extension == 'png';
        
        return {
            title: node.get('name'),
            glyphIcon: isImg ? null : Ametys.file.AbstractFileExplorerTree.getFileIconGlyph(node.get('name')),
            image: isImg ? this._getThumbnail(node) : null,
            imageWidth: isImg ? 100 : 48,
            imageHeight: isImg ? 100 : 48,
            text: text,
            inribbon: false
        };
    },
    
    /**
     * @protected
     * Gets the tooltip thumbnail of an image
     * @param {Ametys.file.AbstractFileExplorerTree.FileNode} node The node
     * @return {String} The url of the thumbnail
     */
    _getThumbnail: function(node)
    {
        return Ametys.getPluginDirectPrefix('core-ui') + "/thumbnail/image?path=" + node.get('path') + "&maxWidth=100&maxHeight=100";
    },
    
    /**
     * This event is thrown by the getDragData to add the 'source' of the drag.
     * @param {Object} item The default drag data that will be transmitted. You have to add a 'source' item in it: 
     * @param {Ametys.relation.RelationPoint} item.source The source (in the relation way) of the drag operation. 
     * @private
     */
    getDragInfo: function(item)
    {
        var targets = [];

        for (var i = 0; i < item.records.length; i++)
        {
            var record = item.records[i];
            targets.push(this.getMessageTargetConfiguration(record, false));
        }

        if (targets.length > 0)
        {
            item.source = {
                relationTypes: [Ametys.relation.Relation.MOVE],
                targets: targets
            };
        }
    },

    /**
     * This event is thrown before the beforeDrop event and create the target of the drop operation relation.
     * @param {Ext.data.Model[]} targetRecords The target records of the drop operation.
     * @param {Object} item The default drag data that will be transmitted. You have to add a 'target' item in it: 
     * @param {Object} item.target The target (in the relation way) of the drop operation. A Ametys.relation.RelationPoint config.   
     * @private
     */
    getDropInfo: function(targetRecords, item)
    {
        var targets = [];

        for (var i = 0; i < targetRecords.length; i++)
        {
            var record = targetRecords[i];
            targets.push(this.getMessageTargetConfiguration(record, false));
        }

        if (targets.length > 0)
        {
            item.target = {
                relationTypes: [Ametys.relation.Relation.MOVE],
                targets: targets
            };
        }
    },

    /**
     * Listener on {@link Ametys.message.Message#CREATED} message. 
     * If the tool is concerned by the created object, the parent node will be refreshed.
     * @param {Ametys.message.Message} message The created message.
     * @private
     */
    _onFileCreated: function(message)
    {
        var folderTarget = message.getTarget(this.getMessageTargetIdForCollection());
        if (folderTarget != null)
        {
            // Refresh parent node and rename new created node
            var path = folderTarget.getParameters().path;
            var parentPath = path.substring(0, path.lastIndexOf('/'));

            if (this.allowNodeEditing)
            {
                this.refreshNode(parentPath, Ext.bind(this._renameNode, this, [folderTarget.getParameters().path], true));
            }
            else
            {
                this.refreshNode(parentPath, Ext.bind(this._selectNodeByPath, this, [folderTarget.getParameters().path]));
            }
            return;
        }
        
        var fileTarget = message.getTarget(this.getMessageTargetIdForResource());
        if (fileTarget != null)
        {
            // Refresh parent node and select new node
            var path = fileTarget.getParameters().path;
            var parentPath = path.substring(0, path.lastIndexOf('/'));
            this.refreshNode(parentPath, Ext.bind(this._selectNodeByPath, this, [path]));
        }
    },

    
    /**
     * Call this function to rename a node
     * @param {Ext.data.Model} parentNode The parent node
     * @param {String} path The path of the node to rename
     */
    _renameNode: function(parentNode, path)
    {
        if (parentNode != null && path != null)
        {
            var node = this.getSelectionModel().getStore().findRecord('path', path);
            this.selectNode(node);

            Ext.defer(this._deferredEdit, 300, this, [node]);
        }
    },
    
    /**
     * Deferred function to edit a node
     * @param {Ext.data.Model} node The node to start edit
     */
    _deferredEdit: function(node)
    {
        this.editingPlugin.startEdit(node, 0);
    },
    
    /**
     * Listener on {@link Ametys.message.Message#DELETED} message. 
     * If the tool is concerned by the deleted object, the object's node will be deleted.
     * @param {Ametys.message.Message} message The deleted message.
     * @private
     */
    _onFileDeleted: function(message)
    {
        var targets = message.getTargets(this.testTarget);
        if (targets.length > 0)
        {
            for (var i=0; i < targets.length; i++)
            {
                this._deleteNode(targets[i].getParameters().path);
            }
        }
    },
    
    /**
     * Deletes a node from the tree and selects the parent node.
     * @param {String} path The path of the node to delete.
     * @private
     */
    _deleteNode: function(path)
    {
        var node = this.getSelectionModel().getStore().findRecord('path', path);
        if (node != null)
        {
            var parentNode = node.parentNode;
            this.getSelectionModel().select([parentNode]);
            node.remove();
        }
    },
   
    /**
     * Listener on {@link Ametys.message.Message#MODIFIED} message. 
     * If the tool is concerned by the modified object, the parent node will be refreshed.
     * @param {Ametys.message.Message} message The modified message.
     * @private
     */
    _onFileModified: function(message)
    {
        var me = this;
        var targets = message.getTargets(this.testTarget);
        
        if (targets.length > 0)
        {
            for (var i=0; i < targets.length; i++)
            {
                var target = targets[i];
                
                var path = target.getParameters().path;
                
                var node = this.getSelectionModel().getStore().findRecord('path', path, 0, false, true); // case sensitive search
                if (node == null)
                {
                    // Node have been renamed or moved
                    var parentPath = path.substring(0, path.lastIndexOf('/'));
                    var parentNode = this.getSelectionModel().getStore().findRecord('path', path);
                    
                    this.refreshNode(parentPath, Ext.bind(this._selectNodeByPath, this, [path]));
                }
                else
                {
                    this.refreshNode(path, Ext.bind(this._selectNodeByPath, this, [path]));
                }
            }
        }
    },

    /**
     * Listener on {@link Ametys.message.Message#MOVED} message. 
     * If the tool is concerned by the modified object, the node will be refreshed.
     * @param {Ametys.message.Message} message The modified message.
     * @private
     */
    _onFileMoved: function(message)
    {
        var targets = message.getTargets(this._checkTarget);
        if (targets.length > 0)
        {
            for (var i = 0; i < targets.length; i++)
            {
                var target = targets[i];
                
                var path = target.getParameters().path;
                if (path)
                {
                    path = path.substr(0, path.lastIndexOf("/"));
                }
                this.refreshNode(path);
                
                var oldPath = message.getParameters().oldPath;
                if (oldPath)
                {
                    var oldNode = this.getSelectionModel().getStore().findRecord('path', oldPath);
                    if (oldNode != null)
                    {
                        oldNode.remove();
                    }
                }
            }
        }
    },
    
    /**
     * @protected
     * Get the id of message target for a file resource
     * @return {String|Function} the message target type or a function testing the target
     */
    getMessageTargetIdForResource: function ()
    {
        throw new Error("The method #getMessageTargetIdForResource is not implemented in " + this.self.getName());
    },
    
    /**
     * @protected
     * Get the id of message target for a folder resource
     * @return {String|Function} the message target type or a function testing the target
     */
    getMessageTargetIdForCollection: function ()
    {
        throw new Error("The method #getMessageTargetIdForCollection is not implemented in " + this.self.getName());
    },

    /**
     * Select a node by its path
     * @param {String} path the path
     */
    _selectNodeByPath: function(path)
    {
        var node = this.getSelectionModel().getStore().findRecord('path', path);
        if (node != null)
        {
            this.selectNode(node);
        }
    },

    /**
     * Listener on {@link Ametys.message.Message#SELECTION_CHANGED} message. 
     * If the tool is concerned by the created object, the parent node will be refreshed.
     * @param {Ametys.message.Message} message The created message.
     * @private
     */
    _onFileSelected: function(message)
    {
        var me = this;
        var target = message.getTarget(this.testTarget);
        
        if (target)
        {
            var node = this.getSelectionModel().getStore().findRecord('path', target.getParameters().path);
            if (node && this.getSelectionModel().isSelected(node))
            {
                return;
            }
            
            this.expandPath ("/" + this.getRootNode().get('name') + "/" + target.getParameters().path, 'name', '/', function (success, lastNode) {
                if (success && lastNode)
                {
                    me.selectNode(lastNode);
                }
            });
        }
    },
    
    /**
     * @protected
     * @template
     * Test if the target matches
     * @param {Ametys.message.MessageTarget} target The target to test.
     */
    testTarget: function (target)
    {
        throw new Error("The method #testTarget is not implemented in " + this.self.getName());
    },
    
    _configureFiltering: function(config)
    {
        var dockedItems = config.dockedItems || [];
        dockedItems.push(this._getFilterToolbarCfg());
        dockedItems.push(this._getNoResultPanelCfg());
        
        config.dockedItems = dockedItems;
        
        this._counter = {};
        this._filterValue = null;
    },
    
    _getFilterToolbarCfg: function()
    {
        return {
            dock: 'top',
            xtype: 'toolbar',
            itemId: 'toolbar',
            layout: {
                type: 'hbox',
                align: 'stretch'
            },
            items: [{
                // Search input
                xtype: 'textfield',
                cls: 'ametys',
                flex: 1,
                maxWidth: 300,
                itemId: 'search-filter-input',
                emptyText: "{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TREE_FILTER}}",
                minLength: 3,
                minLengthText: "{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TREE_FILTER_INVALID}}",
                listeners: {change: Ext.Function.createBuffered(this._searchFilter, 500, this)}
            }, {
                // Clear search filter
                xtype: 'button',
                tooltip: "{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TREE_CLEAR_FILTER}}",
                handler: this._clearSearchFilter,
                scope: this,
                iconCls: 'a-btn-glyph ametysicon-eraser11 size-16',
                cls: 'a-btn-light'
            }]
        };
    },
    
    _getNoResultPanelCfg: function()
    {
        return {
            dock: 'top',
            xtype: 'button',
            hidden: true,
            itemId: 'noresult',
            ui: 'tool-hintmessage',
            text: "{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TREE_FILTER_NO_MATCH}}" + "{{i18n PLUGINS_CORE_UI_FILES_EXPLORER_TREE_FILTER_NO_MATCH_ACTION}}",
            scope: this,
            handler: this._clearSearchFilter
        };
    },
    
    /**
     * This listener is call on 'change' event on filter input field
     * Filters the tree by text input.
     * @private
     */
    _searchFilter: function(field)
    {
        var value = Ext.String.trim(field.getValue());
        if (this._filterValue == value)
        {
            // Do nothing
            return;
        }
        
        this._filterValue = value;
        
        if (value.length >= field.minLength)
        {
            this._filterNodes(value, [this.getRootNode()]);
        }
        else
        {
            this._hideNoResultPanel();
            this.clearFilter();
        }
    },
    
    /**
     * Get the resources the name matches the given value.
     * Only descendant of nodes will be considered in the search.
     * 
     * @param {String} value The value to match
     * @param {Ext.data.Model[]} nodes The nodes where starting search
     * @param {Boolean} [childNodesOnly] set to 'true' if you want to keep the nodes even if no child match. 
     * @param {Ext.data.TreeModel} [rootNode] The node to start filtering 
     * @private
     */
    _filterNodes(value, nodes, childNodesOnly, rootNode)
    {
        // count the number of filter computed
        // to be able to tell when all result are available
        this._filterCounter = 0;
        this._filterTotal = nodes.length;
        this._hasFilterResult = false;
        
        for (var i=0; i < nodes.length; i++)
        {
            var node = nodes[i];
            
            this._getFilteredPath(value, node, childNodesOnly, rootNode);
        }
        
    },
    
    /**
     * Get the path of the child of node that match 'value'.
     * The path are expected to be relative to rootNode.
     * After computing the path, the method is expected to call
     * #_getFilteredPathCb.
     * @protected
     * @template
     */
    _getFilteredPath: function(value, node, childNodesOnly, rootNode)
    {
        throw new Error("The method #_getFilteredPath is not implemented in " + this.self.getName());
    },
    
    _getFilteredPathCb: function(paths, args)
    {
        this._filterCounter++;
        
        var hasResult = false;
        var node = args.node;
        
        if (!paths)
        {
            return;
        }
        
        if (paths.length == 0)
        {
            if (args.childNodesOnly)
            {
                // There is no child nodes matching but some other nodes are matching.
                hasResult = true;
                for (var i=0; i < node.childNodes.length; i++)
                {
                    this.filterBy (function () {return false}, node.childNodes[i]);
                }
            }
            else
            {
                this.filterBy (function () {return false}, node);
            }
        }
        else
        {
            hasResult = true;
            this._expandAndFilter (paths, args.rootNode || node, node);
        }
        
        this._hasFilterResult = hasResult || this._hasFilterResult;
        
        // If all the filter are computed, update the no search result panel
        if (this._filterCounter == this._filterTotal)
        {
            if (!this._hasFilterResult)
            {
                this._showNoResultPanel();
            }
            else
            {
                this._hideNoResultPanel(false)
            }
            
            this._filterCounter = 0;
            this._filterTotal = 0;
            this._hasFilterResult = false;
        }
    },
    
    /**
     * Expand the tree to the given paths. Then filter nodes matching the given paths by calling the #_filterPaths method
     * @param {String[]} paths The path to expand
     * @param {Ext.data.Model} rootNode The concerned root node
     * @param {Ext.data.Model} node The node from which apply filter
     * @private
     */
    _expandAndFilter: function(paths, rootNode, node)
    {
        node = node || rootNode;
        
        this._counter[rootNode.getId()] = paths.length;
        for (var i=0; i < paths.length; i++)
        {
            this.expandPath(rootNode.getPath('name') + paths[i], {
                field:'name',
                callback: Ext.bind (this._filterPaths, this, [paths, rootNode, node])
            });
        }   
    },
    
    /**
     * Filter nodes by path once the last expand has been processed
     * @param {String[]} paths The path to filter by
     * @param {Ext.data.Model} rootNode The concerned root node
     * @param {Ext.data.Model} node The node from which apply filter
     * @private
     */
    _filterPaths: function (paths, rootNode, node)
    {
        // only execute the filterBy after the last expandPath()
        if (--this._counter[rootNode.getId()] == 0)
        {
            var filterFn = Ext.bind (this._filterByPath, this, [paths, rootNode], true);
            
            // Ensure that expand is complete by deferring the filterBy function ...
            Ext.defer(this.filterBy, 50, this, [filterFn, node]);
        }
    },
    
    /**
     * Returns true if the node path is a part of given paths
     * @param {Ext.data.Model} node The node to test
     * @param {String[]} paths The paths
     * @param {Ext.data.Model} rootNode The root node to build the complete paths
     * @private
     */
    _filterByPath: function (node, paths, rootNode)
    {
        var currentPath = node.getPath('name');
        for (var i=0; i < paths.length; i++)
        {
            var path = rootNode.getPath('name') + paths[i] + '/';
            if (path.indexOf(currentPath + '/') == 0)
            {
                return true;
            }
        }
    },
    
    /**
     * Filters by a function. The specified function will be called for each Record in this Store. 
     * If the function returns true the Record is included, otherwise it is filtered out.
     * @param {Function} filterFn A function to be called.
     */
    filterBy: function (filterFn)
    {
        this.clearFilter();
        this.getStore().filterBy(filterFn);
    },
    
    /**
     * Hide the panel showing there is no result.
     * @private
     */
    _hideNoResultPanel: function ()
    {
        this.headerCt.show() // Show column title
        this.body.show() // Show body
        var noResultPanel = this.getDockedComponent('noresult');
        if (noResultPanel) 
        {
            noResultPanel.hide();
        }
    },
    
    /**
     * Show the panel showing there is no result.
     * @private
     */
    _showNoResultPanel: function ()
    {
        this.headerCt.hide() // Hide column title
        this.body.hide() // Hide body
        var noResultPanel = this.getDockedComponent('noresult');
        if (noResultPanel) 
        {
            noResultPanel.show();
        }
    },
    
    /**
     * Clear all filters
     */
    clearFilter: function ()
    {
        this._filterValue = null;
        
        this.getStore().clearFilter();
    },
    
    /**
     * Clear the filter search
     * @param {Ext.Button} btn The button
     * @private
     */
    _clearSearchFilter: function(btn)
    {
        this._hideNoResultPanel();
        
        this.clearFilter();
        
        this.getDockedComponent('toolbar').down('#search-filter-input').reset();
    },
});