Merge branch 'ph-multi-file-editor-restructure-data' into 'master'

Refactored multi-file data structure

See merge request gitlab-org/gitlab-ce!14862
This commit is contained in:
Filipa Lacerda 2017-10-17 17:40:13 +00:00
commit b3f749036e
33 changed files with 524 additions and 737 deletions

View file

@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper';
import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
export default {
data: () => Store,
data() {
return Store;
},
mixins: [RepoMixin],
components: {
RepoSidebar,

View file

@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility';
export default {
mixins: [RepoMixin],
data: () => Store,
data() {
return Store;
},
components: {
PopupDialog,

View file

@ -3,7 +3,9 @@ import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin';
export default {
data: () => Store,
data() {
return Store;
},
mixins: [RepoMixin],
computed: {
buttonLabel() {

View file

@ -5,7 +5,9 @@ import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
const RepoEditor = {
data: () => Store,
data() {
return Store;
},
destroyed() {
if (Helper.monacoInstance) {
@ -93,7 +95,7 @@ const RepoEditor = {
},
blobRaw() {
if (Helper.monacoInstance && !this.isTree) {
if (Helper.monacoInstance) {
this.setupEditor();
}
},

View file

@ -1,107 +1,78 @@
<script>
import TimeAgoMixin from '../../vue_shared/mixins/timeago';
import timeAgoMixin from '../../vue_shared/mixins/timeago';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
const RepoFile = {
mixins: [TimeAgoMixin],
props: {
file: {
type: Object,
required: true,
export default {
mixins: [
repoMixin,
timeAgoMixin,
],
props: {
file: {
type: Object,
required: true,
},
},
isMini: {
type: Boolean,
required: false,
default: false,
computed: {
fileIcon() {
const classObj = {
'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened,
};
return classObj;
},
levelIndentation() {
return {
marginLeft: `${this.file.level * 16}px`,
};
},
},
loading: {
type: Object,
required: false,
default() { return { tree: false }; },
methods: {
linkClicked(file) {
eventHub.$emit('fileNameClicked', file);
},
},
hasFiles: {
type: Boolean,
required: false,
default: false,
},
activeFile: {
type: Object,
required: true,
},
},
computed: {
canShowFile() {
return !this.loading.tree || this.hasFiles;
},
fileIcon() {
const classObj = {
'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading,
};
return classObj;
},
fileIndentation() {
return {
'margin-left': `${this.file.level * 10}px`,
};
},
activeFileClass() {
return {
active: this.activeFile.url === this.file.url,
};
},
},
methods: {
linkClicked(file) {
this.$emit('linkclicked', file);
},
},
};
export default RepoFile;
};
</script>
<template>
<tr
v-if="canShowFile"
class="file"
:class="activeFileClass"
@click.prevent="linkClicked(file)">
<td>
<i
class="fa fa-fw file-icon"
:class="fileIcon"
:style="fileIndentation"
aria-label="file icon">
</i>
<a
:href="file.url"
class="repo-file-name"
:title="file.url">
{{file.name}}
</a>
</td>
<tr
class="file"
@click.prevent="linkClicked(file)">
<td>
<i
class="fa fa-fw file-icon"
:class="fileIcon"
:style="levelIndentation"
aria-hidden="true"
>
</i>
<a
:href="file.url"
class="repo-file-name"
>
{{ file.name }}
</a>
</td>
<template v-if="!isMini">
<td class="hidden-sm hidden-xs">
<div class="commit-message">
<a @click.stop :href="file.lastCommitUrl">
{{file.lastCommitMessage}}
<template v-if="!isMini">
<td class="hidden-sm hidden-xs">
<a
@click.stop
:href="file.lastCommit.url"
class="commit-message"
>
{{ file.lastCommit.message }}
</a>
</div>
</td>
</td>
<td class="hidden-xs text-right">
<span
class="commit-update"
:title="tooltipTitle(file.lastCommitUpdate)">
{{timeFormated(file.lastCommitUpdate)}}
</span>
</td>
</template>
</tr>
<td class="commit-update hidden-xs text-right">
<span :title="tooltipTitle(file.lastCommit.updatedAt)">
{{ timeFormated(file.lastCommit.updatedAt) }}
</span>
</td>
</template>
</tr>
</template>

View file

@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = {
data: () => Store,
data() {
return Store;
},
mixins: [RepoMixin],

View file

@ -1,25 +0,0 @@
<script>
const RepoFileOptions = {
props: {
isMini: {
type: Boolean,
required: false,
default: false,
},
projectName: {
type: String,
required: true,
},
},
};
export default RepoFileOptions;
</script>
<template>
<tr v-if="isMini" class="repo-file-options">
<td>
<span class="title">{{projectName}}</span>
</td>
</tr>
</template>

View file

@ -1,43 +1,23 @@
<script>
const RepoLoadingFile = {
props: {
loading: {
type: Object,
required: false,
default: {},
},
hasFiles: {
type: Boolean,
required: false,
default: false,
},
isMini: {
type: Boolean,
required: false,
default: false,
},
},
import repoMixin from '../mixins/repo_mixin';
computed: {
showGhostLines() {
return this.loading.tree && !this.hasFiles;
export default {
mixins: [
repoMixin,
],
methods: {
lineOfCode(n) {
return `skeleton-line-${n}`;
},
},
},
methods: {
lineOfCode(n) {
return `skeleton-line-${n}`;
},
},
};
export default RepoLoadingFile;
};
</script>
<template>
<tr
v-if="showGhostLines"
class="loading-file">
class="loading-file"
aria-label="Loading files"
>
<td>
<div
class="animation-container animation-container-small">
@ -48,29 +28,28 @@ export default RepoLoadingFile;
</div>
</div>
</td>
<td
v-if="!isMini"
class="hidden-sm hidden-xs">
<div class="animation-container">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
<template v-if="!isMini">
<td
class="hidden-sm hidden-xs">
<div class="animation-container">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
</div>
</td>
</td>
<td
v-if="!isMini"
class="hidden-xs">
<div class="animation-container animation-container-small">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
<td
class="hidden-xs">
<div class="animation-container animation-container-small animation-container-right">
<div
v-for="n in 6"
:key="n"
:class="lineOfCode(n)">
</div>
</div>
</div>
</td>
</td>
</template>
</tr>
</template>

View file

@ -1,38 +1,38 @@
<script>
import RepoMixin from '../mixins/repo_mixin';
import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
const RepoPreviousDirectory = {
props: {
prevUrl: {
type: String,
required: true,
export default {
mixins: [
repoMixin,
],
props: {
prevUrl: {
type: String,
required: true,
},
},
},
mixins: [RepoMixin],
computed: {
colSpanCondition() {
return this.isMini ? undefined : 3;
computed: {
colSpanCondition() {
return this.isMini ? undefined : 3;
},
},
},
methods: {
linkClicked(file) {
this.$emit('linkclicked', file);
methods: {
linkClicked(file) {
eventHub.$emit('goToPreviousDirectoryClicked', file);
},
},
},
};
export default RepoPreviousDirectory;
};
</script>
<template>
<tr class="prev-directory">
<td
:colspan="colSpanCondition"
@click.prevent="linkClicked(prevUrl)">
<a :href="prevUrl">..</a>
</td>
</tr>
<tr class="file prev-directory">
<td
:colspan="colSpanCondition"
class="table-cell"
@click.prevent="linkClicked(prevUrl)"
>
<a :href="prevUrl">...</a>
</td>
</tr>
</template>

View file

@ -4,7 +4,9 @@
import Store from '../stores/repo_store';
export default {
data: () => Store,
data() {
return Store;
},
computed: {
html() {
return this.activeFile.html;

View file

@ -1,9 +1,10 @@
<script>
import _ from 'underscore';
import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFileOptions from './repo_file_options.vue';
import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin';
@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin';
export default {
mixins: [RepoMixin],
components: {
'repo-file-options': RepoFileOptions,
'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile,
},
created() {
window.addEventListener('popstate', this.checkHistory);
},
destroyed() {
eventHub.$off('fileNameClicked', this.fileClicked);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory);
},
mounted() {
eventHub.$on('fileNameClicked', this.fileClicked);
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
},
data() {
return Store;
},
computed: {
flattendFiles() {
const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)]));
data: () => Store,
return _.chain(this.files)
.map(arr => [arr, mapFiles(arr)])
.flatten()
.value();
},
},
methods: {
checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
@ -52,21 +67,21 @@ export default {
},
fileClicked(clickedFile, lineNumber) {
let file = clickedFile;
const file = clickedFile;
if (file.loading) return;
file.loading = true;
if (file.type === 'tree' && file.opened) {
file = Store.removeChildFilesOfTree(file);
file.loading = false;
Helper.setDirectoryToClosed(file);
Store.setActiveLine(lineNumber);
} else {
const openFile = Helper.getFileFromPath(file.url);
if (openFile) {
file.loading = false;
Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber);
} else {
file.loading = true;
Service.url = file.url;
Helper.getContent(file)
.then(() => {
@ -81,7 +96,7 @@ export default {
goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL;
Helper.getContent(null)
Helper.getContent(null, true)
.then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError);
},
@ -92,38 +107,43 @@ export default {
<template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
<table class="table">
<thead v-if="!isMini">
<thead>
<tr>
<th class="name">Name</th>
<th class="hidden-sm hidden-xs last-commit">Last commit</th>
<th class="hidden-xs last-update text-right">Last update</th>
<th
v-if="isMini"
class="repo-file-options title"
>
<strong class="clgray">
{{ projectName }}
</strong>
</th>
<template v-else>
<th class="name">
Name
</th>
<th class="hidden-sm hidden-xs last-commit">
Last commit
</th>
<th class="hidden-xs last-update text-right">
Last update
</th>
</template>
</tr>
</thead>
<tbody>
<repo-file-options
:is-mini="isMini"
:project-name="projectName"
/>
<repo-previous-directory
v-if="isRoot"
v-if="!isRoot && !loading.tree"
:prev-url="prevURL"
@linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
/>
<repo-loading-file
v-if="!flattendFiles.length && loading.tree"
v-for="n in 5"
:key="n"
:loading="loading"
:has-files="!!files.length"
:is-mini="isMini"
/>
<repo-file
v-for="file in files"
v-for="file in flattendFiles"
:key="file.id"
:file="file"
:is-mini="isMini"
@linkclicked="fileClicked(file)"
:is-tree="isTree"
:has-files="!!files.length"
:active-file="activeFile"
/>
</tbody>
</table>

View file

@ -26,11 +26,13 @@ const RepoTab = {
},
methods: {
tabClicked: Store.setActiveFiles,
tabClicked(file) {
Store.setActiveFiles(file);
},
closeTab(file) {
if (file.changed) return;
this.$emit('tabclosed', file);
Store.removeFromOpenedFiles(file);
},
},
};
@ -39,25 +41,28 @@ export default RepoTab;
</script>
<template>
<li @click="tabClicked(tab)">
<a
href="#0"
class="close"
@click.stop.prevent="closeTab(tab)"
:aria-label="closeLabel">
<i
class="fa"
:class="changedClass"
aria-hidden="true">
</i>
</a>
<li
:class="{ active : tab.active }"
@click="tabClicked(tab)"
>
<button
type="button"
class="close-btn"
@click.stop.prevent="closeTab(tab)"
:aria-label="closeLabel">
<i
class="fa"
:class="changedClass"
aria-hidden="true">
</i>
</button>
<a
href="#"
class="repo-tab"
:title="tab.url"
@click.prevent="tabClicked(tab)">
{{tab.name}}
</a>
</li>
<a
href="#"
class="repo-tab"
:title="tab.url"
@click.prevent="tabClicked(tab)">
{{tab.name}}
</a>
</li>
</template>

View file

@ -1,36 +1,29 @@
<script>
import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin';
const RepoTabs = {
mixins: [RepoMixin],
components: {
'repo-tab': RepoTab,
},
data: () => Store,
methods: {
tabClosed(file) {
Store.removeFromOpenedFiles(file);
export default {
mixins: [RepoMixin],
components: {
'repo-tab': RepoTab,
},
},
};
export default RepoTabs;
data() {
return Store;
},
};
</script>
<template>
<ul id="tabs">
<repo-tab
v-for="tab in openedFiles"
:key="tab.id"
:tab="tab"
:class="{'active' : tab.active}"
@tabclosed="tabClosed"
/>
<li class="tabs-divider" />
</ul>
<ul
id="tabs"
class="list-unstyled"
>
<repo-tab
v-for="tab in openedFiles"
:key="tab.id"
:tab="tab"
/>
<li class="tabs-divider" />
</ul>
</template>

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View file

@ -1,3 +1,4 @@
import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
import Service from '../services/repo_service';
import Store from '../stores/repo_store';
import Flash from '../../flash';
@ -25,10 +26,6 @@ const RepoHelper = {
key: '',
isTree(data) {
return Object.hasOwnProperty.call(data, 'blobs');
},
Time: window.performance
&& window.performance.now
? window.performance
@ -58,13 +55,20 @@ const RepoHelper = {
},
setDirectoryOpen(tree, title) {
const file = tree;
if (!file) return undefined;
if (!tree) return;
file.opened = true;
file.icon = 'fa-folder-open';
RepoHelper.updateHistoryEntry(file.url, title);
return file;
Object.assign(tree, {
opened: true,
});
RepoHelper.updateHistoryEntry(tree.url, title);
},
setDirectoryToClosed(entry) {
Object.assign(entry, {
opened: false,
files: [],
});
},
isRenderable() {
@ -81,63 +85,23 @@ const RepoHelper = {
.catch(RepoHelper.loadingError);
},
// when you open a directory you need to put the directory files under
// the directory... This will merge the list of the current directory and the new list.
getNewMergedList(inDirectory, currentList, newList) {
const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
if (!inDirectory) return newListSorted;
const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
if (!indexOfFile) return newListSorted;
return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
},
// within the get new merged list this does the merging of the current list of files
// and the new list of files. The files are never "in" another directory they just
// appear like they are because of the margin.
mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
newList.reverse().forEach((newFile) => {
const fileIndex = indexOfFile + 1;
const file = newFile;
file.level = inDirectory.level + 1;
oldList.splice(fileIndex, 0, file);
});
return oldList;
},
compareFilesCaseInsensitive(a, b) {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (a.level > 0) return 0;
if (aName < bName) { return -1; }
if (aName > bName) { return 1; }
return 0;
},
isRoot(url) {
// the url we are requesting -> split by the project URL. Grab the right side.
const isRoot = !!url.split(Store.projectUrl)[1]
// remove the first "/"
.slice(1)
// split this by "/"
.split('/')
// remove the first two items of the array... usually /tree/master.
.slice(2)
// we want to know the length of the array.
// If greater than 0 not root.
.length;
return isRoot;
},
getContent(treeOrFile) {
getContent(treeOrFile, emptyFiles = false) {
let file = treeOrFile;
if (!Store.files.length) {
Store.loading.tree = true;
}
return Service.getContent()
.then((response) => {
const data = response.data;
if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title'];
if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) {
Store.isRoot = convertPermissionToBoolean(response.headers['is-root']);
Store.isInitialRoot = Store.isRoot;
}
Store.isTree = RepoHelper.isTree(data);
if (!Store.isTree) {
if (file && file.type === 'blob') {
if (!file) file = data;
Store.binary = data.binary;
@ -145,38 +109,40 @@ const RepoHelper = {
// file might be undefined
RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview();
} else if (!Store.isPreviewView()) {
if (!data.render_error) {
Service.getRaw(data.raw_path)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
data.plain = rawResponse.data;
RepoHelper.setFile(data, file);
}).catch(RepoHelper.loadingError);
}
} else if (!Store.isPreviewView() && !data.render_error) {
Service.getRaw(data.raw_path)
.then((rawResponse) => {
Store.blobRaw = rawResponse.data;
data.plain = rawResponse.data;
RepoHelper.setFile(data, file);
}).catch(RepoHelper.loadingError);
}
if (Store.isPreviewView()) {
RepoHelper.setFile(data, file);
}
// if the file tree is empty
if (Store.files.length === 0) {
const parentURL = Service.blobURLtoParentTree(Service.url);
Service.url = parentURL;
RepoHelper.getContent();
}
} else {
// it's a tree
if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
const newDirectory = RepoHelper.dataToListOfFiles(data);
Store.addFilesToDirectory(file, Store.files, newDirectory);
Store.loading.tree = false;
RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
if (emptyFiles) {
Store.files = [];
}
this.addToDirectory(file, data);
Store.prevURL = Service.blobURLtoParentTree(Service.url);
}
}).catch(RepoHelper.loadingError);
},
addToDirectory(file, data) {
const tree = file || Store;
const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0));
tree.files = files;
},
setFile(data, file) {
const newFile = data;
newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
@ -190,57 +156,39 @@ const RepoHelper = {
Store.setActiveFiles(newFile);
},
serializeBlob(blob) {
const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
simpleBlob.lastCommitMessage = blob.last_commit.message;
simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
simpleBlob.loading = false;
return simpleBlob;
},
serializeTree(tree) {
return RepoHelper.serializeRepoEntity('tree', tree);
},
serializeSubmodule(submodule) {
return RepoHelper.serializeRepoEntity('submodule', submodule);
},
serializeRepoEntity(type, entity) {
serializeRepoEntity(type, entity, level = 0) {
const { url, name, icon, last_commit } = entity;
const returnObj = {
return {
type,
name,
url,
level,
icon: `fa-${icon}`,
level: 0,
files: [],
loading: false,
opened: false,
// eslint-disable-next-line camelcase
lastCommit: last_commit ? {
url: `${Store.projectUrl}/commit/${last_commit.id}`,
message: last_commit.message,
updatedAt: last_commit.committed_date,
} : {},
};
if (entity.last_commit) {
returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
} else {
returnObj.lastCommitUrl = '';
}
return returnObj;
},
scrollTabsRight() {
// wait for the transition. 0.1 seconds.
setTimeout(() => {
const tabs = document.getElementById('tabs');
if (!tabs) return;
tabs.scrollLeft = tabs.scrollWidth;
}, 200);
const tabs = document.getElementById('tabs');
if (!tabs) return;
tabs.scrollLeft = tabs.scrollWidth;
},
dataToListOfFiles(data) {
dataToListOfFiles(data, level) {
const { blobs, trees, submodules } = data;
return [
...blobs.map(blob => RepoHelper.serializeBlob(blob)),
...trees.map(tree => RepoHelper.serializeTree(tree)),
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
];
},

View file

@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service';
import Store from './stores/repo_store';
import Repo from './components/repo.vue';
@ -33,6 +34,8 @@ function setInitialStore(data) {
Store.onTopOfBranch = data.onTopOfBranch;
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
Store.customBranchURL = decodeURIComponent(data.blobUrl);
Store.isRoot = convertPermissionToBoolean(data.root);
Store.isInitialRoot = convertPermissionToBoolean(data.root);
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable();
Store.setBranchHash();

View file

@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service';
const RepoStore = {
monaco: {},
monacoLoading: false,
service: '',
canCommit: false,
onTopOfBranch: false,
editMode: false,
isTree: false,
isRoot: false,
isRoot: null,
isInitialRoot: null,
prevURL: '',
projectId: '',
projectName: '',
@ -39,23 +38,11 @@ const RepoStore = {
newMrTemplateUrl: '',
branchChanged: false,
commitMessage: '',
binaryTypes: {
png: false,
md: false,
svg: false,
unknown: false,
},
loading: {
tree: false,
blob: false,
},
resetBinaryTypes() {
Object.keys(RepoStore.binaryTypes).forEach((key) => {
RepoStore.binaryTypes[key] = false;
});
},
setBranchHash() {
return Service.getBranch()
.then((data) => {
@ -72,10 +59,6 @@ const RepoStore = {
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
},
addFilesToDirectory(inDirectory, currentList, newList) {
RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
},
toggleRawPreview() {
RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
@ -129,30 +112,6 @@ const RepoStore = {
RepoStore.activeFileLabel = 'Display source';
},
removeChildFilesOfTree(tree) {
let foundTree = false;
const treeToClose = tree;
let canStopSearching = false;
RepoStore.files = RepoStore.files.filter((file) => {
const isItTheTreeWeWant = file.url === treeToClose.url;
// if it's the next tree
if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
canStopSearching = true;
return true;
}
if (canStopSearching) return true;
if (isItTheTreeWeWant) foundTree = true;
if (foundTree) return file.level <= treeToClose.level;
return true;
});
treeToClose.opened = false;
treeToClose.icon = 'fa-folder';
return treeToClose;
},
removeFromOpenedFiles(file) {
if (file.type === 'tree') return;
let foundIndex;
@ -186,6 +145,7 @@ const RepoStore = {
if (openedFilesAlreadyExists) return;
openFile.changed = false;
openFile.active = true;
RepoStore.openedFiles.push(openFile);
},

View file

@ -198,6 +198,13 @@ a {
height: 12px;
}
&.animation-container-right {
.skeleton-line-2 {
left: 0;
right: 150px;
}
}
&::before {
animation-duration: 1s;
animation-fill-mode: forwards;

View file

@ -153,28 +153,13 @@
overflow-x: auto;
li {
animation: swipeRightAppear ease-in 0.1s;
animation-iteration-count: 1;
transform-origin: 0% 50%;
list-style-type: none;
position: relative;
background: $gray-normal;
display: inline-block;
padding: #{$gl-padding / 2} $gl-padding;
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
white-space: nowrap;
cursor: pointer;
&.remove {
animation: swipeRightDissapear ease-in 0.1s;
animation-iteration-count: 1;
transform-origin: 0% 50%;
a {
width: 0;
}
}
&.active {
background: $white-light;
border-bottom: none;
@ -182,17 +167,21 @@
a {
@include str-truncated(100px);
color: $black;
color: $gl-text-color;
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
}
&.close {
width: auto;
font-size: 15px;
opacity: 1;
margin-right: -6px;
}
.close-btn {
position: absolute;
right: 8px;
top: 50%;
padding: 0;
background: none;
border: 0;
font-size: $gl-font-size;
transform: translateY(-50%);
}
.close-icon:hover {
@ -201,9 +190,6 @@
.close-icon,
.unsaved-icon {
float: right;
margin-top: 3px;
margin-left: 15px;
color: $gray-darkest;
}
@ -222,9 +208,7 @@
#repo-file-buttons {
background-color: $white-light;
border-bottom: 1px solid $white-normal;
padding: 5px 10px;
position: relative;
border-top: 1px solid $white-normal;
}
@ -287,37 +271,23 @@
overflow: auto;
}
table {
.table {
margin-bottom: 0;
}
tr {
animation: fadein 0.5s;
cursor: pointer;
&.repo-file-options td {
padding: 0;
border-top: none;
background: $gray-light;
.repo-file-options {
padding: 2px 16px;
width: 100%;
display: inline-block;
}
&:first-child {
border-top-left-radius: 2px;
}
.title {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
font-weight: $gl-font-weight-bold;
color: $gray-darkest;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
padding: 2px 16px;
}
.title {
font-size: 10px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.file-icon {
@ -329,11 +299,13 @@
}
}
.file {
cursor: pointer;
}
a {
@include str-truncated(250px);
color: $almost-black;
display: inline-block;
vertical-align: middle;
}
}
}

View file

@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
format.json do
page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
response.header['is-root'] = @path.empty?
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
Gitlab::GitalyClient.allow_n_plus_1_calls do

View file

@ -1,4 +1,5 @@
#repo{ data: { url: content_url,
#repo{ data: { root: @path.empty?.to_s,
url: content_url,
project_name: project.name,
refs_url: refs_project_path(project, format: :json),
project_url: project_path(project),

View file

@ -134,6 +134,7 @@ describe('RepoCommitSection', () => {
afterEach(() => {
vm.$destroy();
el.remove();
RepoStore.openedFiles = [];
});
it('shows commit message', () => {

View file

@ -9,6 +9,10 @@ describe('RepoEditButton', () => {
return new RepoEditButton().$mount();
}
afterEach(() => {
RepoStore.openedFiles = [];
});
it('renders an edit button that toggles the view state', (done) => {
RepoStore.isCommitable = true;
RepoStore.changedFiles = [];
@ -38,12 +42,4 @@ describe('RepoEditButton', () => {
expect(vm.$el.innerHTML).toBeUndefined();
});
describe('methods', () => {
describe('editCancelClicked', () => {
it('sets dialog to open when there are changedFiles');
it('toggles editMode and calls toggleBlobView');
});
});
});

View file

@ -1,4 +1,5 @@
import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store';
import repoEditor from '~/repo/components/repo_editor.vue';
describe('RepoEditor', () => {
@ -8,6 +9,10 @@ describe('RepoEditor', () => {
this.vm = new RepoEditor().$mount();
});
afterEach(() => {
RepoStore.openedFiles = [];
});
it('renders an ide container', (done) => {
this.vm.openedFiles = ['idiidid'];
this.vm.binary = false;

View file

@ -9,6 +9,10 @@ describe('RepoFileButtons', () => {
return new RepoFileButtons().$mount();
}
afterEach(() => {
RepoStore.openedFiles = [];
});
it('renders Raw, Blame, History, Permalink and Preview toggle', () => {
const activeFile = {
extension: 'md',

View file

@ -1,33 +0,0 @@
import Vue from 'vue';
import repoFileOptions from '~/repo/components/repo_file_options.vue';
describe('RepoFileOptions', () => {
const projectName = 'projectName';
function createComponent(propsData) {
const RepoFileOptions = Vue.extend(repoFileOptions);
return new RepoFileOptions({
propsData,
}).$mount();
}
it('renders the title and new file/folder buttons if isMini is true', () => {
const vm = createComponent({
isMini: true,
projectName,
});
expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy();
expect(vm.$el.querySelector('.title').textContent).toEqual(projectName);
});
it('does not render if isMini is false', () => {
const vm = createComponent({
isMini: false,
projectName,
});
expect(vm.$el.innerHTML).toBeFalsy();
});
});

View file

@ -1,21 +1,11 @@
import Vue from 'vue';
import repoFile from '~/repo/components/repo_file.vue';
import RepoStore from '~/repo/stores/repo_store';
import eventHub from '~/repo/event_hub';
import { file } from '../mock_data';
describe('RepoFile', () => {
const updated = 'updated';
const file = {
icon: 'icon',
url: 'url',
name: 'name',
lastCommitMessage: 'message',
lastCommitUpdate: Date.now(),
level: 10,
};
const activeFile = {
pageTitle: 'pageTitle',
url: 'url',
};
const otherFile = {
html: '<p class="file-content">html</p>',
pageTitle: 'otherpageTitle',
@ -29,12 +19,15 @@ describe('RepoFile', () => {
}).$mount();
}
beforeEach(() => {
RepoStore.openedFiles = [];
});
it('renders link, icon, name and last commit details', () => {
const RepoFile = Vue.extend(repoFile);
const vm = new RepoFile({
propsData: {
file,
activeFile,
file: file(),
},
});
spyOn(vm, 'timeFormated').and.returnValue(updated);
@ -43,28 +36,20 @@ describe('RepoFile', () => {
const name = vm.$el.querySelector('.repo-file-name');
const fileIcon = vm.$el.querySelector('.file-icon');
expect(vm.$el.classList.contains('active')).toBeTruthy();
expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px');
expect(name.title).toEqual(file.url);
expect(name.href).toMatch(`/${file.url}`);
expect(name.textContent.trim()).toEqual(file.name);
expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(file.lastCommitMessage);
expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px');
expect(name.href).toMatch(`/${vm.file.url}`);
expect(name.textContent.trim()).toEqual(vm.file.name);
expect(vm.$el.querySelector('.commit-message').textContent.trim()).toBe(vm.file.lastCommit.message);
expect(vm.$el.querySelector('.commit-update').textContent.trim()).toBe(updated);
expect(fileIcon.classList.contains(file.icon)).toBeTruthy();
expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`);
expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy();
expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`);
});
it('does render if hasFiles is true and is loading tree', () => {
const vm = createComponent({
file,
activeFile,
loading: {
tree: true,
},
hasFiles: true,
file: file(),
});
expect(vm.$el.innerHTML).toBeTruthy();
expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy();
});
@ -75,75 +60,51 @@ describe('RepoFile', () => {
});
it('renders a spinner if the file is loading', () => {
file.loading = true;
const f = file();
f.loading = true;
const vm = createComponent({
file,
activeFile,
loading: {
tree: true,
},
hasFiles: true,
file: f,
});
expect(vm.$el.innerHTML).toBeTruthy();
expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`);
});
it('does not render if loading tree', () => {
const vm = createComponent({
file,
activeFile,
loading: {
tree: true,
},
});
expect(vm.$el.innerHTML).toBeFalsy();
expect(vm.$el.querySelector('.fa-spin.fa-spinner')).not.toBeNull();
expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${vm.file.level * 16}px`);
});
it('does not render commit message and datetime if mini', () => {
RepoStore.openedFiles.push(file());
const vm = createComponent({
file,
activeFile,
isMini: true,
file: file(),
});
expect(vm.$el.querySelector('.commit-message')).toBeFalsy();
expect(vm.$el.querySelector('.commit-update')).toBeFalsy();
});
it('does not set active class if file is active file', () => {
const vm = createComponent({
file,
activeFile: {},
});
expect(vm.$el.classList.contains('active')).toBeFalsy();
});
it('fires linkClicked when the link is clicked', () => {
const vm = createComponent({
file,
activeFile,
file: file(),
});
spyOn(vm, 'linkClicked');
vm.$el.querySelector('.repo-file-name').click();
vm.$el.click();
expect(vm.linkClicked).toHaveBeenCalledWith(file);
expect(vm.linkClicked).toHaveBeenCalledWith(vm.file);
});
describe('methods', () => {
describe('linkClicked', () => {
const vm = jasmine.createSpyObj('vm', ['$emit']);
it('$emits fileNameClicked with file obj', () => {
spyOn(eventHub, '$emit');
it('$emits linkclicked with file obj', () => {
const theFile = {};
const vm = createComponent({
file: file(),
});
repoFile.methods.linkClicked.call(vm, theFile);
vm.linkClicked(vm.file);
expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile);
expect(eventHub.$emit).toHaveBeenCalledWith('fileNameClicked', vm.file);
});
});
});

View file

@ -1,4 +1,5 @@
import Vue from 'vue';
import RepoStore from '~/repo/stores/repo_store';
import repoLoadingFile from '~/repo/components/repo_loading_file.vue';
describe('RepoLoadingFile', () => {
@ -28,6 +29,10 @@ describe('RepoLoadingFile', () => {
});
}
afterEach(() => {
RepoStore.openedFiles = [];
});
it('renders 3 columns of animated LoC', () => {
const vm = createComponent({
loading: {
@ -42,38 +47,16 @@ describe('RepoLoadingFile', () => {
});
it('renders 1 column of animated LoC if isMini', () => {
RepoStore.openedFiles = new Array(1);
const vm = createComponent({
loading: {
tree: true,
},
hasFiles: false,
isMini: true,
});
const columns = [...vm.$el.querySelectorAll('td')];
expect(columns.length).toEqual(1);
assertColumns(columns);
});
it('does not render if tree is not loading', () => {
const vm = createComponent({
loading: {
tree: false,
},
hasFiles: false,
});
expect(vm.$el.innerHTML).toBeFalsy();
});
it('does not render if hasFiles is true', () => {
const vm = createComponent({
loading: {
tree: true,
},
hasFiles: true,
});
expect(vm.$el.innerHTML).toBeFalsy();
});
});

View file

@ -1,5 +1,6 @@
import Vue from 'vue';
import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue';
import eventHub from '~/repo/event_hub';
describe('RepoPrevDirectory', () => {
function createComponent(propsData) {
@ -20,7 +21,7 @@ describe('RepoPrevDirectory', () => {
spyOn(vm, 'linkClicked');
expect(link.href).toMatch(`/${prevUrl}`);
expect(link.textContent).toEqual('..');
expect(link.textContent).toEqual('...');
link.click();
@ -29,14 +30,17 @@ describe('RepoPrevDirectory', () => {
describe('methods', () => {
describe('linkClicked', () => {
const vm = jasmine.createSpyObj('vm', ['$emit']);
it('$emits linkclicked with prevUrl', () => {
const prevUrl = 'prevUrl';
const vm = createComponent({
prevUrl,
});
it('$emits linkclicked with file obj', () => {
const file = {};
spyOn(eventHub, '$emit');
repoPrevDirectory.methods.linkClicked.call(vm, file);
vm.linkClicked(prevUrl);
expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file);
expect(eventHub.$emit).toHaveBeenCalledWith('goToPreviousDirectoryClicked', prevUrl);
});
});
});

View file

@ -3,6 +3,7 @@ import Helper from '~/repo/helpers/repo_helper';
import RepoService from '~/repo/services/repo_service';
import RepoStore from '~/repo/stores/repo_store';
import repoSidebar from '~/repo/components/repo_sidebar.vue';
import { file } from '../mock_data';
describe('RepoSidebar', () => {
let vm;
@ -15,14 +16,15 @@ describe('RepoSidebar', () => {
afterEach(() => {
vm.$destroy();
RepoStore.files = [];
RepoStore.openedFiles = [];
});
it('renders a sidebar', () => {
RepoStore.files = [{
id: 0,
}];
RepoStore.files = [file()];
RepoStore.openedFiles = [];
RepoStore.isRoot = false;
RepoStore.isRoot = true;
vm = createComponent();
const thead = vm.$el.querySelector('thead');
@ -30,9 +32,9 @@ describe('RepoSidebar', () => {
expect(vm.$el.id).toEqual('sidebar');
expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
expect(thead.querySelector('.name').textContent).toEqual('Name');
expect(thead.querySelector('.last-commit').textContent).toEqual('Last commit');
expect(thead.querySelector('.last-update').textContent).toEqual('Last update');
expect(thead.querySelector('.name').textContent.trim()).toEqual('Name');
expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit');
expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update');
expect(tbody.querySelector('.repo-file-options')).toBeFalsy();
expect(tbody.querySelector('.prev-directory')).toBeFalsy();
expect(tbody.querySelector('.loading-file')).toBeFalsy();
@ -46,76 +48,74 @@ describe('RepoSidebar', () => {
vm = createComponent();
expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeFalsy();
expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy();
expect(vm.$el.querySelector('thead')).toBeTruthy();
expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy();
});
it('renders 5 loading files if tree is loading and not hasFiles', () => {
RepoStore.loading = {
tree: true,
};
RepoStore.loading.tree = true;
RepoStore.files = [];
vm = createComponent();
expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5);
});
it('renders a prev directory if isRoot', () => {
RepoStore.files = [{
id: 0,
}];
RepoStore.isRoot = true;
it('renders a prev directory if is not root', () => {
RepoStore.files = [file()];
RepoStore.isRoot = false;
RepoStore.loading.tree = false;
vm = createComponent();
expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy();
});
describe('flattendFiles', () => {
it('returns a flattend array of files', () => {
const f = file();
f.files.push(file('testing 123'));
const files = [f, file()];
vm = createComponent();
vm.files = files;
expect(vm.flattendFiles.length).toBe(3);
expect(vm.flattendFiles[1].name).toBe('testing 123');
});
});
describe('methods', () => {
describe('fileClicked', () => {
it('should fetch data for new file', () => {
spyOn(Helper, 'getContent').and.callThrough();
const file1 = {
id: 0,
url: '',
};
RepoStore.files = [file1];
RepoStore.files = [file()];
RepoStore.isRoot = true;
vm = createComponent();
vm.fileClicked(file1);
vm.fileClicked(RepoStore.files[0]);
expect(Helper.getContent).toHaveBeenCalledWith(file1);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[0]);
});
it('should not fetch data for already opened files', () => {
const file = {
id: 42,
url: 'foo',
};
spyOn(Helper, 'getFileFromPath').and.returnValue(file);
const f = file();
spyOn(Helper, 'getFileFromPath').and.returnValue(f);
spyOn(RepoStore, 'setActiveFiles');
vm = createComponent();
vm.fileClicked(file);
vm.fileClicked(f);
expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(file);
expect(RepoStore.setActiveFiles).toHaveBeenCalledWith(f);
});
it('should hide files in directory if already open', () => {
spyOn(RepoStore, 'removeChildFilesOfTree').and.callThrough();
const file1 = {
id: 0,
type: 'tree',
url: '',
opened: true,
};
RepoStore.files = [file1];
RepoStore.isRoot = true;
spyOn(Helper, 'setDirectoryToClosed').and.callThrough();
const f = file();
f.opened = true;
f.type = 'tree';
RepoStore.files = [f];
vm = createComponent();
vm.fileClicked(file1);
vm.fileClicked(RepoStore.files[0]);
expect(RepoStore.removeChildFilesOfTree).toHaveBeenCalledWith(file1);
expect(Helper.setDirectoryToClosed).toHaveBeenCalledWith(RepoStore.files[0]);
});
});
@ -131,36 +131,31 @@ describe('RepoSidebar', () => {
});
describe('back button', () => {
const file1 = {
id: 1,
url: 'file1',
};
const file2 = {
id: 2,
url: 'file2',
};
RepoStore.files = [file1, file2];
RepoStore.openedFiles = [file1, file2];
RepoStore.isRoot = true;
beforeEach(() => {
const f = file();
const file2 = Object.assign({}, file());
file2.url = 'test';
RepoStore.files = [f, file2];
RepoStore.openedFiles = [];
RepoStore.isRoot = true;
vm = createComponent();
vm.fileClicked(file1);
vm = createComponent();
});
it('render previous file when using back button', () => {
spyOn(Helper, 'getContent').and.callThrough();
vm.fileClicked(file2);
expect(Helper.getContent).toHaveBeenCalledWith(file2);
Helper.getContent.calls.reset();
vm.fileClicked(RepoStore.files[1]);
expect(Helper.getContent).toHaveBeenCalledWith(RepoStore.files[1]);
history.pushState({
key: Math.random(),
}, '', file1.url);
}, '', RepoStore.files[1].url);
const popEvent = document.createEvent('Event');
popEvent.initEvent('popstate', true, true);
window.dispatchEvent(popEvent);
expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(file1.url);
expect(Helper.getContent.calls.mostRecent().args[0].url).toContain(RepoStore.files[1].url);
window.history.pushState({}, null, '/');
});

View file

@ -1,5 +1,6 @@
import Vue from 'vue';
import repoTab from '~/repo/components/repo_tab.vue';
import RepoStore from '~/repo/stores/repo_store';
describe('RepoTab', () => {
function createComponent(propsData) {
@ -18,7 +19,7 @@ describe('RepoTab', () => {
const vm = createComponent({
tab,
});
const close = vm.$el.querySelector('.close');
const close = vm.$el.querySelector('.close-btn');
const name = vm.$el.querySelector(`a[title="${tab.url}"]`);
spyOn(vm, 'closeTab');
@ -44,26 +45,43 @@ describe('RepoTab', () => {
tab,
});
expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy();
expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy();
});
describe('methods', () => {
describe('closeTab', () => {
const vm = jasmine.createSpyObj('vm', ['$emit']);
it('returns undefined and does not $emit if file is changed', () => {
const file = { changed: true };
const returnVal = repoTab.methods.closeTab.call(vm, file);
const tab = {
url: 'url',
name: 'name',
changed: true,
};
const vm = createComponent({
tab,
});
expect(returnVal).toBeUndefined();
expect(vm.$emit).not.toHaveBeenCalled();
spyOn(RepoStore, 'removeFromOpenedFiles');
vm.$el.querySelector('.close-btn').click();
expect(RepoStore.removeFromOpenedFiles).not.toHaveBeenCalled();
});
it('$emits tabclosed event with file obj', () => {
const file = { changed: false };
repoTab.methods.closeTab.call(vm, file);
const tab = {
url: 'url',
name: 'name',
changed: false,
};
const vm = createComponent({
tab,
});
expect(vm.$emit).toHaveBeenCalledWith('tabclosed', file);
spyOn(RepoStore, 'removeFromOpenedFiles');
vm.$el.querySelector('.close-btn').click();
expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(tab);
});
});
});

View file

@ -16,6 +16,10 @@ describe('RepoTabs', () => {
return new RepoTabs().$mount();
}
afterEach(() => {
RepoStore.openedFiles = [];
});
it('renders a list of tabs', () => {
RepoStore.openedFiles = openedFiles;
@ -28,18 +32,4 @@ describe('RepoTabs', () => {
expect(tabs[1].classList.contains('active')).toBeFalsy();
expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
});
describe('methods', () => {
describe('tabClosed', () => {
it('calls removeFromOpenedFiles with file obj', () => {
const file = {};
spyOn(RepoStore, 'removeFromOpenedFiles');
repoTabs.methods.tabClosed(file);
expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file);
});
});
});
});

View file

@ -0,0 +1,13 @@
import RepoHelper from '~/repo/helpers/repo_helper';
// eslint-disable-next-line import/prefer-default-export
export const file = (name = 'name') => RepoHelper.serializeRepoEntity('blob', {
icon: 'icon',
url: 'url',
name,
last_commit: {
id: '123',
message: 'test',
committed_date: '',
},
});