File Manager cz. II - unit testy, filtry i CSS AngularJS

Kategoria: AngularJS

Autor: admin@ignaszewski.pl | Opublikowany: 4 maja 2014

Po krótkiej przerwie przyszedł czas na konkretne programowanie w naszym projekcie. Ale jak wiadomo dobre programowanie należałoby zacząć od ... no właśnie od czego jak nie od testów jednostkowych. W naszym projekcie do testowania naszych skryptów JS użyjemy Karmy. Ale po koleji...

Karma - instalacja i konfiguracja

Karma tak jak i inne przez nas używane pakiety jest napisana w JS i można ją zainstalować w bardzo prosty sposób przy użyciu pakietów nodejs. Dodajmy do naszego pliku package.json nastepujące linijki:

"grunt-karma": "0.8.3",
"karma-jasmine": "0.1.5",
"karma-phantomjs-launcher": "0.1.2"

Teraz pozostaje wykonać polecenie npm install, aby dograć brakujące pakiety. W ten sposób zainstalowaliśmy Karmę, która będzie stanowiła silnik naszych testów, jej plugin jasmine, który umożliwi nam pisanie testów oraz phantomjs-launcher, który uruchomi testy poza środowiskiem przeglądarki (można zainstalować także inne pluginy przeglądarek: Chrome, FireFox, Opera, Safari czy IE).

Przyszedł więc czas na to, aby skonfigurować nasze środowisko testowe. Załóżmy, że wszystkie testy będziemy trzymać w katalogu: tests/unit, a konfigurację Karmy w pliku tests/karma.config.js, który przedstawia się następująco:

// tests/karma.config.js
module.exports = function(config) { config.set({ frameworks: ['jasmine'], files: [ '../client/components/angular/*.js', '../client/components/angular-route/*.js', '../client/components/angular-mocks/*.js', '../client/components/lodash/*.js', '../client/src/js/app/app.js', '../client/src/js/app/controllers.js', 'unit/**/*.test.js' ], reporters: ['progress'], preprocessors: { '../../client/js/src/app/**/*.js': ['coverage'] }, coverageReporter: { type : 'html', dir : 'coverage/' },
port: 9876, colors: true, browsers: ['PhantomJS'], autoWatch: true,
singleRun: false }); };

Teraz krótko omówię poszczególne opcje konfiguracyjne (użyte przeze  mnie powyżej nie stanowią pełnej listy możliwości):

  • frameworks - lista pluginów, które chcemy użyć, w naszym przypadku jest to jasmine, w której będziemy pisać poszczególne testy
  • files - lista plików które składają się na naszą aplikację, niezbędne biblioteki zewnętrzne, jak i pliki z testami jednostkowymi
  • reporters - lista raportów jakie chcemy uzyskać (progress - pokaże tylko informację ile testów pozytywnie przeszło), dostępne są także inne (w przyszłości skorzystamy z coverage, którego tyczą się w szczególności poniższe dwie opcje)
  • preprocessors - lista obserwowanych plików w kontekście danego testu
  • coverageReporter - sposób zaraportowania pokrycia kodu (u nas będzie html umiejscowiony w katalogu tests/coverage)
  • port - port na którym jest uruchamiana Karma
  • colors - kolorowanie składni pojawiających się komunikatów
  • browsers -  w jakich przeglądarkach mają zostać uruchomione nasze tetsy
  • autoWatch - czy nasłuchiwać zmian na plikach
  • singleRun - czy uruchomić testy jednokrotnie

Ostatnie dwie opcje pozwolą nam na jednokrotnie uruchomić polecenie: grunt karma, a w trakcie zmian na naszych plikach źródłowych karma będzie samoczynnie uruchamiać testy.

Pozostało nam  jeszcze dodać tylko do pliku Gruntfile.js kilka linijek:

karma: {
unit: {
configFile: 'tests/karma.config.js'
}
},
...
grunt.loadNpmTasks('grunt-karma'); 

i mamy już w pełni skonfigurowane środowisko testowe. Pozostaje napisać już tylko testy jednostkowe, ale o tym za chwilę.

Testy jednostkowe

Na początek utwórzmy plik tests/unit.controller.test.js w którym przetestujemy podstawowe ustawienia naszego kontorlera:

describe('controllers.js', function () {

    beforeEach(module('filemanager'));

    describe('MainCtrl:', function () {

    });
});

Na początku pliku załadujmy moduł filemanager abyśmy mogli korzystać z naszego kodu. Ponieważ w pliku controllers.js może być w przyszłości wiele kontrolerów, to dla przejrzystości naszych testów utwórzmy sekcję MainCtrl, w której zawrzemy wszystkie nasze testy jednostkowe.

Będziemy także potrzebować funkcji, która utworzy nam nasz kontroler i wstrzyknie do niego wszystkie potrzebne zależności. Dobrze byłoby, aby tworzył się on przed każdym testem jednostkowym na nowo.

    describe('MainCtrl:', function () { 
var $scope, $rootScope, createMainCtrl; beforeEach(inject(function($injector){ var $controller = $injector.get('$controller'); $rootScope = $injector.get('$rootScope'); $scope = $rootScope.$new(); createMainCtrl = function(){ return $controller('MainCtrl', { '$scope': $scope, '$http': $injector.get('$http') }) } }));
beforeEach(function(){ createMainCtrl(); });
}); 

W pierwszej kolejności sprawdźmy nasze podstawowe dane (listę plików i forlderów) czy mają wartość pustej tablicy.

        beforeEach(function(){
            createMainCtrl();
        });

        describe('primary values', function () {
            it('dirs should be empty array', function () {
                expect($scope.dirs.length).toBe(0);
            });

            it('files should be empty array', function () {
                expect($scope.files.length).toBe(0);
            });
        });

Jak widać pisanie testów jednostkowych nie jest takie trudne. Jeśli ktoś chciałby bardziej zgłębić te tajniki to odsyłam do tutorialu AngularJS lub do dokumentacji Jasmine.

Serwisy

W AngularJS istnieje kila typów serwisów: constat, value, service, factory i provider. Każdy z nich wyróżnia się specyficzną konstrukcją i cechami, jednak jest jedna rzecz, która je wszystkie łączy - każdy jest singletonem. Więcej szczegółów na ten temat znajdziecie w artykule Understanding Service Types.
Wszystkie serwisy dzięki temu, że są singletonami świetnie sprawują się jako pośrednik wymiany danych między kontrolerami i w takiej roli często się pojawiają w różnych aplikacjach.

Na początku naszej aplikacji stworzymy dwa serwisy:

  • FileTypes - będzie zawierał pogrupowane typy mime plików, które będą odpowiadały filtrom znajdującym się w górnej części naszej aplikacji (grafika, wideo, audio, archiwa)
  • FileIcons - będzie udostępniał funkcję zwracającą url do miniatury typu pliku, który nie jest plikiem graficznym

W tym celu stwórzmy plik services.js o następującej treści:

angular.module('filemanager')
    .service('FileTypes', function(){
        this.images = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/png'];
        this.audio = ['audio/mpeg', 'audio/x-ms-wma', 'audio/vnd.rn-realaudio', 'audio/x-wav'];
        this.video = ['video/mpeg', 'video/mp4', 'video/quicktime', 'video/x-ms-wmv'];
        this.archive = ['application/zip'];
    })
    .service('FileIcons', function(){
        this.imagesExtensions = ['aac', 'ai', 'aiff', 'avi', '_blank', 'bmp', 'c', 'cpp', 'css', 'dat', 'dmg', 'doc', 'dotx', 'dwg', 'dxf', 'eps', 'exe', 'flv', 'gif', 'h', 'hpp', 'html', 'ics', 'iso', 'java', 'jpg', 'js', 'key', 'less', 'mid', 'mp3', 'mp4', 'mpg', 'odf', 'ods', 'odt', 'otp', 'ots', 'ott', '_page', 'pdf', 'php', 'png', 'ppt', 'psd', 'py', 'qt', 'rar', 'rb', 'rtf', 'sass', 'scss', 'sql', 'tga', 'tgz', 'tiff', 'txt', 'wav', 'xls', 'xlsx', 'xml', 'yml', 'zip'];
    })
;

O ile serwis FileType nie wymaga napisania żadnych testów, o tyle FileIcons już tak. Wiemy, że ma zawierać funkcję zwracającą url do ikony reprezentującej dany typ pliku. Napiszmy zatem najpierw odpowiedni test jednostkowy, który sprawdzi poprawność naszego kodu.

// tests/unit/services.test.js
describe('services.js', function () {
    beforeEach(module('filemanager'));

    describe('FileIconsService', function () {
        var fileIcons;

        beforeEach(inject(function($injector){
            fileIcons = $injector.get('FileIcons');
        }));


        it('should return /data/icons/_blank.png for file name equal moj.jar', function () {
            expect(fileIcons.getIconPath('moj.jar')).toEqual('/data/icons/_blank.png');
        });

        it('should return /data/icons/pdf.png for file name equal moj.pdf', function () {
            expect(fileIcons.getIconPath('moj.pdf')).toEqual('/data/icons/pdf.png');
        });

    });
});

Mając tak przygotowane testy możemy przystąpić do napisania funkcji getIconPath(filename). Jej zadaniem będzie sprawdzić czy rozszerzenie pliku znajduje się na liście dostępnych ikon i zwrócić odpowiednią ścieżkę. Jeśli rozszerzenie nie zostanie znalezione, zwraca ikonę _blank.png

service('FileIcons', function(){
        this.imagesExtensions = ['aac', 'ai', 'aiff', 'avi', '_blank', 'bmp', 'c', 'cpp', 'css', 'dat', 'dmg', 'doc', 'dotx', 'dwg', 'dxf', 'eps', 'exe', 'flv', 'gif', 'h', 'hpp', 'html', 'ics', 'iso', 'java', 'jpg', 'js', 'key', 'less', 'mid', 'mp3', 'mp4', 'mpg', 'odf', 'ods', 'odt', 'otp', 'ots', 'ott', '_page', 'pdf', 'php', 'png', 'ppt', 'psd', 'py', 'qt', 'rar', 'rb', 'rtf', 'sass', 'scss', 'sql', 'tga', 'tgz', 'tiff', 'txt', 'wav', 'xls', 'xlsx', 'xml', 'yml', 'zip'];

        this.getIconPath = function(fileName){
            var ext = fileName.substr(_.lastIndexOf(fileName, '.') + 1);
            if(this.imagesExtensions.indexOf(ext) > -1)
            {
                return '/data/icons/' + ext + '.png';
            }
            else
            {
                return '/data/icons/_blank.png';
            }
        }
    })

Być może w tej chwili nie do końca widać potrzebę tworzenia takich serwisów, ale obiecuję, że w późniejszej części bardzo się nam przydadzą.

Modele

Modele, co to takiego? Właściwie ciężko na to pytanie odpowiedzieć. AngularJS jest uważany za framework typu MVM (Model View Model) w którym ciężko wyizolować model, gdyż często przeplata się on z funkcjonalnością kontrolera. Przeciwieństwem są frameworki MVC (Model View Controller), gdzie Model jest specjalnie wyizolowaną częścią naszego kodu.

Ja w swoich aplikacjach staram się mimo wszystko wyodrębnić Modele, dzięki czemu uzyskuje się ładniej wyglądający kod i większą jego elastyczność (łatwiej też konstruować testy).
Także tutaj postanowiłem stworzyć dwa modele:

  • FileObj - model pliku z danego katalogu
  • DirObj - model katalogu

Na początek stwórzmy kilka prostych testów:

// tests/unit/services.test.js
describe('models.js', function () {
    var FileObj, DirObj;
    beforeEach(module('filemanager'));

    beforeEach(inject(function($injector){
        FileObj = $injector.get('FileObj');
        DirObj = $injector.get('DirObj');
    }));

    describe('FileObj:', function () {
        var imageFileObj = {
            "id": 21,
            "name": "Dino 8",
            "src": "/data/images/IMG_5583.JPG",
            "mime": "image/jpg"
        }
        pdfFileObj = {
            "id": 50,
            "name": "Przykładowy PDF",
            "src": "/data/pdf/przykladowy_pdf.pdf",
            "mime": "application/pdf"
        }
        ;

        describe('functions:', function () {

            it('setData: should set data for new object correctly', function () {
                var fileObj = new FileObj({id: 7, name: 'Nowy plik', mime: 'image/jpg'});
                expect(fileObj.id).toBe(7);
                expect(fileObj.name).toEqual('Nowy plik');
            });

            it('isImage: should return true for mime: image/jpg', function () {
                var fileObj = new FileObj(imageFileObj);
                expect(fileObj.isImage()).toBeTruthy();
            });

            it('isImage: should return false for mime: application/pdf', function () {
                var fileObj = new FileObj(pdfFileObj);
                expect(fileObj.isImage()).toBeFalsy();
            });

        });
    });

    describe('DirObj:', function () {

        describe('functions:', function () {

            describe('setData:', function () {
                it('should set data for new object correctly', function () {
                    var dirObj = new DirObj({id: 11, name: 'Nowy katalog'});
                    expect(dirObj.id).toBe(11);
                    expect(dirObj.name).toEqual('Nowy katalog');
                });
            });

        });
    });
});

Myślę, że nie będziecie mieli większych problemów z przeanalizowaniem tych testów. Jednak na wszelki wypadek, krótko wytłumaczę o co chodzi. Oba obiekty powinny mieć funkcję setData(data), która przyjmie dane otrzymane z serwera (JSON) i przekaże je jako właściwości naszego obiektu. Dodatkowo obiekt pliku (FileObj) musi posiadać funkcję isImage(), zwracającą true/false, jeśli obiekt jest grafiką lub nie.

Skoro wiemy już jak mają wyglądać nasze obiekty to przyszedł czas na ich napisanie:

// src/js/app/modules.js
angular.module('filemanager')
    .factory('FileObj', ['FileTypes', 'FileIcons', function(FileTypes, FileIcons){
        function FileObj(data) {
            this.image = false;
            this.icon = false;
            this.setData(data);
        }

        FileObj.prototype = {
            setData: function(data){
                angular.extend(this, data);

                this.image = (FileTypes.images.indexOf(this.mime) > -1) ? true : false;
                if(!this.image)
                {
                    this.icon = FileIcons.getIconPath(this.src);
                }
            },
            isImage: function(){
                return this.image;
            }
        }

        return FileObj;
    }])
    .factory('DirObj', function(){
        function DirObj(data) {
            this.setData(data);
        }

        DirObj.prototype = {
            setData: function(data){
                angular.extend(this, data);
            }
        }

        return DirObj;
    })
;

Jak możemy zauważyć każdy z naszych obiektów jest serwisem typu factory, zwracają one konstruktor obiektów, dzięki czemu używając słowa kluczowego new możemy tworzyć wiele instancji takiego obiektu.

FileObj w swoim konstruktorze ustawia dwie dodatkowe właściwości:

  • image - true/false - czy jest plikiem graficznym
  • icon - false/string - ścieżka do ikony reprezentującej typ pliku jeśli plik nie jest plikiem graficznym

Myślę, że reszta kodu nie wymaga dodatkowego komentarza.

Kontroler i szablon HTML 

Ponieważ przygotowaliśmy sobie nieco kodu w postaci serwisów i modeli, to spróbujmy go wykorzystać w naszym kontrolerze i szablonie HTML.

W pierwszej kolejności użyjmy obiektów FIleObj i DirObj aby zastąpiły nasze dotychczasowe wartości w tablicach $scope.files i $scope.dir. Zaznaczmy przy tym, że zmieniła się nieco struktura naszego pliku /data/directory.json. Każdy element tablicy files ma teraz dodatkową cechę mime i wygląda następująco:

        {
            "id": 50,
            "name": "Przykładowy PDF",
            "src": "/data/pdf/przykladowy_pdf.pdf",
            "mime": "application/pdf"
        }

Zmodyfikujmy zapytanie wczytujące dane:

$http.get('/data/directory.json')
            .success(function(data){
 data.dirs.forEach(function(dirData){ $scope.dirs.push(new DirObj(dirData)); }); data.files.forEach(function(fileData){ $scope.files.push(new FileObj(fileData)); });             })
        ;

nie zapomnijmy przy tym o dodaniu do deklaracji kontrolera odpowiednich parametrów

.controller('MainCtrl', ['$scope', '$http', 'DirObj', 'FileObj', function($scope, $http, DirObj, FileObj){
…
 })

Taka zmiana pozwoli nam zmodyfikować nieco kod HTML, tak aby dla plików typu graficznego wyświetlał miniaturkę, a dla innych typów plików odpowiednią ikonę.

<div ng-repeat="file in files" class="thumb thumb-file img-thumbnail">
<img ng-if="file.isImage()" ng-src="{{ file.src }}" class="thumb-image" >
<img ng-if="!file.isImage()" ng-src="{{ file.icon }}" class="thumb-image thumb-icon" >
<div class="thumb-name" data-ng-bind="file.name"></div>
</div>

Wprowadźmy także w przypadku plików i katalogów sortowanie po nazwie:

<div ng-repeat="dir in dirs | orderBy:'name'" class="thumb thumb-folder img-thumbnail">

</div>
<div ng-repeat="file in files | orderBy:'name'" class="thumb thumb-file img-thumbnail">

</div>
Kolejnym etapem będzie ożywienie pola umożliwiającego filtrowanie plików po ich nazwie. W tym celu dodamy do pola input atrybut ng-model, a do atrybutów ng-repeat odpowiednie filtry.

<div class="col-sm-4 col-md-4 col-md-push-1 col-sm-push-1">
   <div class="input-group">
       <input ng-model="search" type="text" class="form-control" placeholder="Filtruj">
       <span class="btn btn-default input-group-addon"><i class="fa fa-search"></i></span>
   </div>
</div>

<div ng-repeat="dir in dirs | filter:{'name': search } | orderBy:'name'" class="thumb thumb-folder img-thumbnail">

</div>
<div ng-repeat="file in files | filter:{'name': search } | orderBy:'name'" class="thumb thumb-file img-thumbnail">

</div> 

Na koniec pozostało nam tylko ożywić przyciski umożliwiające filtrowanie po typie pliku. Dodajmy zatem do kontrolera pole przechowującego wartość obecnie wybranego filtra (false jeśli żaden nie został wybrany), a także zmienną, która przez referencję odwołuje się do serwisu FileTypes.

// clients/src/js/controllers.js

/**
 * Current file type filter name (false = off)
 * @type {boolean|string}
 */
$scope.fileTypeFilter = false;

/**
 * List file  mime types
 * @type {{images: Array, audio: Array, video: Array, archive: Array}}
 */
$scope.fileTypes = FileTypes;

Dodatkowo dodajmy też funkcję, która umożliwi nam ustawienie obecnego filtru:

/**
 * Change filter file type name
 *
 * @param filterName
 */
$scope.setFilterType = function(filterName){
    if(filterName && filterName !== false && $scope.fileTypes[filterName])
    {
        $scope.fileTypeFilter = filterName;
    }
    else
    {
        $scope.fileTypeFilter = false;
    }
};

Oczywiście wszystko musi być poparte testami jednostkowymi:

describe('primary values', function () {
    …
    it('default fileTypeFilter should have been false', function () {
        expect($scope.fileTypeFilter).toBeFalsy();
    });
});


describe('functions:', function () {
    describe('setFilterType:', function () {
        it('should set type images', function () {
            $scope.setFilterType('images');
            expect($scope.fileTypeFilter).toEqual('images');
        });

        it('should clear filter', function () {
            $scope.setFilterType();
            expect($scope.fileTypeFilter).toBeFalsy();
        });

        it('use unknown filter name should clear filter', function () {
            $scope.setFilterType('photoshop');
            expect($scope.fileTypeFilter).toBeFalsy();
        });
    });
});

Teraz jeszcze drobne zmiany w naszym pliku HTML

<div class="btn-group">
   <button ng-click="setFilterType(false)" ng-class="{'active': !fileTypeFilter}" class="btn btn-default" title="Wszystkie typy plików"><i class="fa fa-file-o"></i></button>
   <button ng-click="setFilterType('images')" ng-class="{'active': fileTypeFilter == 'images'}" class="btn btn-default" title="Zdjęcia"><i class="fa fa-picture-o"></i></button>
   <button ng-click="setFilterType('audio')" ng-class="{'active': fileTypeFilter == 'audio'}" class="btn btn-default" title="Audio"><i class="fa fa-music"></i></button>
   <button ng-click="setFilterType('video')" ng-class="{'active': fileTypeFilter == 'video'}" class="btn btn-default" title="Wideo"><i class="fa fa-video-camera"></i></button>
   <button ng-click="setFilterType('archive')" ng-class="{'active': fileTypeFilter == 'archive'}" class="btn btn-default" title="Archiwa"><i class="fa fa-archive"></i></button>
</div>
i byłoby już OK, gdyby nie to, że nie zmienia się nam lista plików, aby wszystko zadziałało prawidłowo potrzebny będzie nam odpowiedni filtr.
 

Filtry

AngularJS posiada możliwość definiowania własnych filtrów i używania ich w kodzie HTML poprzez "| nazwa_filtru" lub w dowolnym innym miejscu poprzez użycie serwisu $filter.

My stworzymy sobie filtr, którego zadaniem będzie przefiltrowanie zadanej tablicy pod kątem plików o zadanych typach mime. Filtr powinien spełniać następujące testy:

// tests/unit/filters.test.js
describe('filters.js', function () {
    beforeEach(module('filemanager'));

    describe('fileMimeType:', function () {
        var fileMimeFilter,
            filesList = [
                {
                    "id": 1,
                    "name": "Dino 1",
                    "src": "/data/images/IMG_5549.JPG",
                    "mime": "image/jpg"
                },
                {
                    "id": 10,
                    "name": "Dino 2 - PDF dokument",
                    "src": "/data/pdf/some.pdf",
                    "mime": "application/pdf"
                },
                {
                    "id": 11,
                    "name": "Dino 3",
                    "src": "/data/images/IMG_5559.JPG",
                    "mime": "image/jpg"
                },
                {
                    "id": 2,
                    "name": "Dino 4",
                    "src": "/data/images/IMG_5565.JPG",
                    "mime": "image/jpg"
                },
                {
                    "id": 4,
                    "name": "Dino 5",
                    "src": "/data/images/IMG_5567.JPG",
                    "mime": "image/jpg"
                }
            ],
            imagesMimes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/png'],
            pdfMimes = ['application/pdf'],
            $filter
        ;

        beforeEach(inject(function($injector){
            $filter = $injector.get('$filter');
            fileMimeFilter = $filter('fileMime');
        }));

        it('images mimes should return 4 items', function () {
            expect(fileMimeFilter(filesList, imagesMimes).length).toBe(4);
        });

        it('pdf mimes should return 1 item', function () {
            expect(fileMimeFilter(filesList, pdfMimes).length).toBe(1);
        });

        it('empty mime list should return 5 items', function () {
            expect(fileMimeFilter(filesList, []).length).toBe(5);
        });

        it('undefined mime list should return 5 items', function () {
            expect(fileMimeFilter(filesList).length).toBe(5);
        });

    });
});

Skoro wiemy co ma robić nasz filtr wystraczy teraz napisać krótki kawałek kodu, który będzie to realizował

// client/src/js/filters.js
angular.module('filemanager')
    .filter('fileMime', function(){
        return function(filesList, fileMimeTypesList){
            var files = [];
            if(typeof fileMimeTypesList === 'undefined' || fileMimeTypesList.length === 0)
            {
                return filesList;
            }

            filesList.forEach(function(file){
                if(fileMimeTypesList.indexOf(file.mime) > -1)
                {
                    files.push(file);
                }
            });

            return files;
        };
    })
;

a następnie użyć go w naszym pliku HTML

<div ng-repeat="file in files | fileMime:(fileTypes[fileTypeFilter] || []) | filter:{'name': search } | orderBy:'name'" class="thumb thumb-file img-thumbnail">

</div>

Dzięki temu nasz mechanizm powinien zacząć działać tak jak byśmy tego chcieli.

CSS

Na sam koniec pozostało nieco drobnych zmian w samym plik mian.less, tak by nasza cała aplikacja prezentowała się w miarę sensowny sposób (proszę mi wybaczyć wszelki design - nie jestem grafikiem).
Dodałem takie efekty jak rozjaśnienie i zmiana koloru ramki przy najechaniu na miniaturkę, wypozycjonowanie ikony reprezentującej dany typ pliku i inne drobne poprawki.

@containerClass: filemanager;
@thumbWidth: 180px;
@thumbHeight: 140px;
@thumbNameHeight: 30px;
.@{containerClass} {
	.nav-row {
		margin: 0 0 10px 0;

		div:first-child {
			margin-left: 0;
			padding-left: 0;
		}

		div:last-child {
			margin-right: 0;
			padding-right: 0;
		}
	}

	.btn {
		height: 34px;

		// Ikonki w przyciskach
		.fa {
			margin-right: 5px;
			&:last-child {
				margin-right: 2px;
			}
		}
	}

	.main-panel {
		.thumb {
			width: @thumbWidth;
			height: @thumbHeight;
			margin: 20px;
			cursor: pointer;

			&:hover {
				border: 1px solid red;
				opacity: 0.7;
			}

			.thumb-image {
				height: @thumbHeight - @thumbNameHeight - 10;
				text-align: center;

				.fa {
					font-size: @thumbHeight - @thumbNameHeight - 10;
				}
			}

			.thumb-name {
				height:  @thumbNameHeight;
				line-height:  @thumbNameHeight;
				background-color: #eeeeee;
				border-radius: 3px;
				padding: 0 3px;
				overflow: hidden;
				white-space:nowrap;
				text-overflow: ellipsis;
				text-align: center;
			}

			&.thumb-folder {

				text-align: center;

				.thumb-image {
					&.fa {
						font-size: @thumbHeight - @thumbNameHeight;
					}
				}
			}

			&.thumb-file {
				.thumb-image {
					width: @thumbWidth - 10;
					border-radius: 3px;

					&.thumb-icon {
						width: 80px;
						height: 80px;
						margin: 10px (@thumbWidth - 80)/2;
					}
				}
			}

		}
	}
}

Teraz powinieneś ujrzęć taki sam stan aplikacji jak w wersji 0.0.2 na githubie.