Commit b901df22 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'ide-rename-files' into 'master'

Enable renaming files & folders in the Web IDE

Closes #44845

See merge request gitlab-org/gitlab-ce!20835
parents 97285407 19eecd01
...@@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue'; ...@@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue';
import newModal from './modal.vue'; import newModal from './modal.vue';
import upload from './upload.vue'; import upload from './upload.vue';
import ItemButton from './button.vue'; import ItemButton from './button.vue';
import { modalTypes } from '../../constants';
export default { export default {
components: { components: {
...@@ -56,6 +57,7 @@ export default { ...@@ -56,6 +57,7 @@ export default {
this.dropdownOpen = !this.dropdownOpen; this.dropdownOpen = !this.dropdownOpen;
}, },
}, },
modalTypes,
}; };
</script> </script>
...@@ -74,7 +76,7 @@ export default { ...@@ -74,7 +76,7 @@ export default {
@click.stop="openDropdown()" @click.stop="openDropdown()"
> >
<icon <icon
name="hamburger" name="ellipsis_v"
/> />
<icon <icon
name="arrow-down" name="arrow-down"
...@@ -106,11 +108,20 @@ export default { ...@@ -106,11 +108,20 @@ export default {
class="d-flex" class="d-flex"
icon="folder-new" icon="folder-new"
icon-classes="mr-2" icon-classes="mr-2"
@click="createNewItem('tree')" @click="createNewItem($options.modalTypes.tree)"
/> />
</li> </li>
<li class="divider"></li> <li class="divider"></li>
</template> </template>
<li>
<item-button
:label="__('Rename')"
class="d-flex"
icon="pencil"
icon-classes="mr-2"
@click="createNewItem($options.modalTypes.rename)"
/>
</li>
<li> <li>
<item-button <item-button
:label="__('Delete')" :label="__('Delete')"
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { __ } from '~/locale'; import { __ } from '~/locale';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue'; import GlModal from '~/vue_shared/components/gl_modal.vue';
import { modalTypes } from '../../constants';
export default { export default {
components: { components: {
...@@ -13,42 +14,58 @@ export default { ...@@ -13,42 +14,58 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['newEntryModal']), ...mapState(['entryModal']),
entryName: { entryName: {
get() { get() {
return this.name || (this.newEntryModal.path !== '' ? `${this.newEntryModal.path}/` : ''); if (this.entryModal.type === modalTypes.rename) {
return this.name || this.entryModal.entry.name;
}
return this.name || (this.entryModal.path !== '' ? `${this.entryModal.path}/` : '');
}, },
set(val) { set(val) {
this.name = val; this.name = val;
}, },
}, },
modalTitle() { modalTitle() {
if (this.newEntryModal.type === 'tree') { if (this.entryModal.type === modalTypes.tree) {
return __('Create new directory'); return __('Create new directory');
} else if (this.entryModal.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
} }
return __('Create new file'); return __('Create new file');
}, },
buttonLabel() { buttonLabel() {
if (this.newEntryModal.type === 'tree') { if (this.entryModal.type === modalTypes.tree) {
return __('Create directory'); return __('Create directory');
} else if (this.entryModal.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
} }
return __('Create file'); return __('Create file');
}, },
}, },
methods: { methods: {
...mapActions(['createTempEntry']), ...mapActions(['createTempEntry', 'renameEntry']),
createEntryInStore() { submitForm() {
if (this.entryModal.type === modalTypes.rename) {
this.renameEntry({
path: this.entryModal.entry.path,
name: this.entryName,
});
} else {
this.createTempEntry({ this.createTempEntry({
name: this.name, name: this.name,
type: this.newEntryModal.type, type: this.entryModal.type,
}); });
}
}, },
focusInput() { focusInput() {
setTimeout(() => {
this.$refs.fieldName.focus(); this.$refs.fieldName.focus();
}); },
closedModal() {
this.name = '';
}, },
}, },
}; };
...@@ -60,8 +77,9 @@ export default { ...@@ -60,8 +77,9 @@ export default {
:header-title-text="modalTitle" :header-title-text="modalTitle"
:footer-primary-button-text="buttonLabel" :footer-primary-button-text="buttonLabel"
footer-primary-button-variant="success" footer-primary-button-variant="success"
@submit="createEntryInStore" @submit="submitForm"
@open="focusInput" @open="focusInput"
@closed="closedModal"
> >
<div <div
class="form-group row" class="form-group row"
......
...@@ -134,8 +134,7 @@ export default { ...@@ -134,8 +134,7 @@ export default {
.replace(/[/]$/g, ''); .replace(/[/]$/g, '');
// - strip ending "/" // - strip ending "/"
const filePath = this.file.path const filePath = this.file.path.replace(/[/]$/g, '');
.replace(/[/]$/g, '');
return filePath === routePath; return filePath === routePath;
}, },
...@@ -194,7 +193,7 @@ export default { ...@@ -194,7 +193,7 @@ export default {
data-container="body" data-container="body"
data-placement="right" data-placement="right"
name="file-modified" name="file-modified"
css-classes="prepend-left-5 multi-file-modified" css-classes="prepend-left-5 ide-file-modified"
/> />
</span> </span>
<changed-file-icon <changed-file-icon
...@@ -208,7 +207,6 @@ export default { ...@@ -208,7 +207,6 @@ export default {
</span> </span>
<new-dropdown <new-dropdown
:type="file.type" :type="file.type"
:branch="file.branchId"
:path="file.path" :path="file.path"
:mouse-over="mouseOver" :mouse-over="mouseOver"
class="float-right prepend-left-8" class="float-right prepend-left-8"
......
...@@ -53,3 +53,8 @@ export const commitItemIconMap = { ...@@ -53,3 +53,8 @@ export const commitItemIconMap = {
class: 'ide-file-deletion', class: 'ide-file-deletion',
}, },
}; };
export const modalTypes = {
rename: 'rename',
tree: 'tree',
};
...@@ -8,7 +8,7 @@ export default { ...@@ -8,7 +8,7 @@ export default {
}); });
}, },
getRawFileData(file) { getRawFileData(file) {
if (file.tempFile) { if (file.tempFile && !file.prevPath) {
return Promise.resolve(file.content); return Promise.resolve(file.content);
} }
......
...@@ -186,13 +186,39 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => { ...@@ -186,13 +186,39 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
}; };
export const deleteEntry = ({ commit, dispatch, state }, path) => { export const deleteEntry = ({ commit, dispatch, state }, path) => {
dispatch('burstUnusedSeal'); const entry = state.entries[path];
dispatch('closeFile', state.entries[path]);
if (state.unusedSeal) dispatch('burstUnusedSeal');
if (entry.opened) dispatch('closeFile', entry);
if (entry.type === 'tree') {
entry.tree.forEach(f => dispatch('deleteEntry', f.path));
}
commit(types.DELETE_ENTRY, path); commit(types.DELETE_ENTRY, path);
if (entry.parentPath && state.entries[entry.parentPath].tree.length === 0) {
dispatch('deleteEntry', entry.parentPath);
}
}; };
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => {
const entry = state.entries[entryPath || path];
commit(types.RENAME_ENTRY, { path, name, entryPath });
if (entry.type === 'tree') {
state.entries[entryPath || path].tree.forEach(f =>
dispatch('renameEntry', { path, name, entryPath: f.path }),
);
}
if (!entryPath) {
dispatch('deleteEntry', path);
}
};
export * from './actions/tree'; export * from './actions/tree';
export * from './actions/file'; export * from './actions/file';
export * from './actions/project'; export * from './actions/project';
......
...@@ -62,14 +62,14 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { ...@@ -62,14 +62,14 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => {
const file = state.entries[path]; const file = state.entries[path];
if (file.raw || file.tempFile) return Promise.resolve(); if (file.raw || (file.tempFile && !file.prevPath)) return Promise.resolve();
commit(types.TOGGLE_LOADING, { entry: file }); commit(types.TOGGLE_LOADING, { entry: file });
const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url;
return service return service
.getFileData( .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${url.replace('/-/', '/')}`)
`${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`,
)
.then(({ data, headers }) => { .then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers); const normalizedHeaders = normalizeHeaders(headers);
setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
...@@ -101,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) = ...@@ -101,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) =
service service
.getRawFileData(file) .getRawFileData(file)
.then(raw => { .then(raw => {
if (!file.tempFile) commit(types.SET_FILE_RAW_DATA, { file, raw }); if (!(file.tempFile && !file.prevPath)) commit(types.SET_FILE_RAW_DATA, { file, raw });
if (file.mrChange && file.mrChange.new_file === false) { if (file.mrChange && file.mrChange.new_file === false) {
service service
.getBaseRawFileData(file, baseSha) .getBaseRawFileData(file, baseSha)
...@@ -176,9 +176,22 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => { ...@@ -176,9 +176,22 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => {
export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
const file = state.entries[path]; const file = state.entries[path];
if (file.deleted && file.parentPath) {
dispatch('restoreTree', file.parentPath);
}
if (file.movedPath) {
commit(types.DISCARD_FILE_CHANGES, file.movedPath);
commit(types.REMOVE_FILE_FROM_CHANGED, file.movedPath);
}
commit(types.DISCARD_FILE_CHANGES, path); commit(types.DISCARD_FILE_CHANGES, path);
commit(types.REMOVE_FILE_FROM_CHANGED, path); commit(types.REMOVE_FILE_FROM_CHANGED, path);
if (file.prevPath) {
dispatch('discardFileChanges', file.prevPath);
}
if (file.tempFile && file.opened) { if (file.tempFile && file.opened) {
commit(types.TOGGLE_FILE_OPEN, path); commit(types.TOGGLE_FILE_OPEN, path);
} else if (getters.activeFile && file.path === getters.activeFile.path) { } else if (getters.activeFile && file.path === getters.activeFile.path) {
......
...@@ -89,3 +89,13 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = ...@@ -89,3 +89,13 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } =
resolve(); resolve();
} }
}); });
export const restoreTree = ({ dispatch, commit, state }, path) => {
const entry = state.entries[path];
commit(types.RESTORE_TREE, path);
if (entry.parentPath) {
dispatch('restoreTree', entry.parentPath);
}
};
...@@ -67,9 +67,9 @@ export const someUncommitedChanges = state => ...@@ -67,9 +67,9 @@ export const someUncommitedChanges = state =>
!!(state.changedFiles.length || state.stagedFiles.length); !!(state.changedFiles.length || state.stagedFiles.length);
export const getChangesInFolder = state => path => { export const getChangesInFolder = state => path => {
const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length; const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f.path, path)).length;
const stagedFilesCount = state.stagedFiles.filter( const stagedFilesCount = state.stagedFiles.filter(
f => filePathMatches(f, path) && !getChangedFile(state)(f.path), f => filePathMatches(f.path, path) && !getChangedFile(state)(f.path),
).length; ).length;
return changedFilesCount + stagedFilesCount; return changedFilesCount + stagedFilesCount;
......
...@@ -77,3 +77,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; ...@@ -77,3 +77,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY'; export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
...@@ -131,11 +131,14 @@ export default { ...@@ -131,11 +131,14 @@ export default {
}, },
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) { [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path); const changedFile = state.changedFiles.find(f => f.path === file.path);
const { prevPath } = file;
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
raw: file.content, raw: file.content,
changed: !!changedFile, changed: !!changedFile,
staged: false, staged: false,
prevPath: '',
moved: false,
lastCommit: Object.assign(state.entries[file.path].lastCommit, { lastCommit: Object.assign(state.entries[file.path].lastCommit, {
id: lastCommit.commit.id, id: lastCommit.commit.id,
url: lastCommit.commit_path, url: lastCommit.commit_path,
...@@ -144,6 +147,18 @@ export default { ...@@ -144,6 +147,18 @@ export default {
updatedAt: lastCommit.commit.authored_date, updatedAt: lastCommit.commit.authored_date,
}), }),
}); });
if (prevPath) {
// Update URLs after file has moved
const regex = new RegExp(`${prevPath}$`);
Object.assign(state.entries[file.path], {
rawPath: file.rawPath.replace(regex, file.path),
permalink: file.permalink.replace(regex, file.path),
commitsPath: file.commitsPath.replace(regex, file.path),
blamePath: file.blamePath.replace(regex, file.path),
});
}
}, },
[types.BURST_UNUSED_SEAL](state) { [types.BURST_UNUSED_SEAL](state) {
Object.assign(state, { Object.assign(state, {
...@@ -169,7 +184,11 @@ export default { ...@@ -169,7 +184,11 @@ export default {
}, },
[types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) { [types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
Object.assign(state, { Object.assign(state, {
newEntryModal: { type, path }, entryModal: {
type,
path,
entry: { ...state.entries[path] },
},
}); });
}, },
[types.DELETE_ENTRY](state, path) { [types.DELETE_ENTRY](state, path) {
...@@ -179,8 +198,48 @@ export default { ...@@ -179,8 +198,48 @@ export default {
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
entry.deleted = true; entry.deleted = true;
state.changedFiles = state.changedFiles.concat(entry);
parent.tree = parent.tree.filter(f => f.path !== entry.path); parent.tree = parent.tree.filter(f => f.path !== entry.path);
if (entry.type === 'blob') {
state.changedFiles = state.changedFiles.concat(entry);
}
},
[types.RENAME_ENTRY](state, { path, name, entryPath = null }) {
const oldEntry = state.entries[entryPath || path];
const nameRegex =
!entryPath && oldEntry.type === 'blob'
? new RegExp(`${oldEntry.name}$`)
: new RegExp(`^${path}`);
const newPath = oldEntry.path.replace(nameRegex, name);
const parentPath = oldEntry.parentPath ? oldEntry.parentPath.replace(nameRegex, name) : '';
state.entries[newPath] = {
...oldEntry,
id: newPath,
key: `${name}-${oldEntry.type}-${oldEntry.id}`,
path: newPath,
name: entryPath ? oldEntry.name : name,
tempFile: true,
prevPath: oldEntry.path,
url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath),
tree: [],
parentPath,
raw: '',
};
oldEntry.moved = true;
oldEntry.movedPath = newPath;
const parent = parentPath
? state.entries[parentPath]
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
const newEntry = state.entries[newPath];
parent.tree = sortTree(parent.tree.concat(newEntry));
if (newEntry.type === 'blob') {
state.changedFiles = state.changedFiles.concat(newEntry);
}
}, },
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
......
...@@ -53,15 +53,25 @@ export default { ...@@ -53,15 +53,25 @@ export default {
}, },
[types.SET_FILE_RAW_DATA](state, { file, raw }) { [types.SET_FILE_RAW_DATA](state, { file, raw }) {
const openPendingFile = state.openFiles.find( const openPendingFile = state.openFiles.find(
f => f.path === file.path && f.pending && !f.tempFile, f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath),
); );
if (file.tempFile) {
Object.assign(state.entries[file.path], {
content: raw,
});
} else {
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
raw, raw,
}); });
}
if (!openPendingFile) return;
if (openPendingFile) { if (!openPendingFile.tempFile) {
openPendingFile.raw = raw; openPendingFile.raw = raw;
} else if (openPendingFile.tempFile) {
openPendingFile.content = raw;
} }
}, },
[types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) {
...@@ -119,12 +129,14 @@ export default { ...@@ -119,12 +129,14 @@ export default {
[types.DISCARD_FILE_CHANGES](state, path) { [types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path); const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path]; const entry = state.entries[path];
const { deleted } = entry; const { deleted, prevPath } = entry;
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content: stagedFile ? stagedFile.content : state.entries[path].raw, content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false, changed: false,
deleted: false, deleted: false,
moved: false,
movedPath: '',
}); });
if (deleted) { if (deleted) {
...@@ -133,6 +145,12 @@ export default { ...@@ -133,6 +145,12 @@ export default {
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
parent.tree = sortTree(parent.tree.concat(entry)); parent.tree = sortTree(parent.tree.concat(entry));
} else if (prevPath) {
const parent = entry.parentPath
? state.entries[entry.parentPath]
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
parent.tree = parent.tree.filter(f => f.path !== path);
} }
}, },
[types.ADD_FILE_TO_CHANGED](state, path) { [types.ADD_FILE_TO_CHANGED](state, path) {
......
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import { sortTree } from '../utils';
export default { export default {
[types.TOGGLE_TREE_OPEN](state, path) { [types.TOGGLE_TREE_OPEN](state, path) {
...@@ -36,4 +37,14 @@ export default { ...@@ -36,4 +37,14 @@ export default {
changedFiles: [], changedFiles: [],
}); });
}, },
[types.RESTORE_TREE](state, path) {
const entry = state.entries[path];
const parent = entry.parentPath
? state.entries[entry.parentPath]
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
if (!parent.tree.find(f => f.path === path)) {
parent.tree = sortTree(parent.tree.concat(entry));
}
},
}; };
...@@ -26,8 +26,9 @@ export default () => ({ ...@@ -26,8 +26,9 @@ export default () => ({
rightPane: null, rightPane: null,
links: {}, links: {},
errorMessage: null, errorMessage: null,
newEntryModal: { entryModal: {
type: '', type: '',
path: '', path: '',
entry: {},
}, },
}); });
...@@ -47,6 +47,9 @@ export const dataStructure = () => ({ ...@@ -47,6 +47,9 @@ export const dataStructure = () => ({
lastOpenedAt: 0, lastOpenedAt: 0,
mrChange: null, mrChange: null,
deleted: false, deleted: false,
prevPath: '',
movedPath: '',
moved: false,
}); });
export const decorateData = entity => { export const decorateData = entity => {
...@@ -107,7 +110,9 @@ export const setPageTitle = title => { ...@@ -107,7 +110,9 @@ export const setPageTitle = title => {
}; };
export const commitActionForFile = file => { export const commitActionForFile = file => {
if (file.deleted) { if (file.prevPath) {
return 'move';
} else if (file.deleted) {
return 'delete'; return 'delete';
} else if (file.tempFile) { } else if (file.tempFile) {
return 'create'; return 'create';
...@@ -116,15 +121,12 @@ export const commitActionForFile = file => { ...@@ -116,15 +121,12 @@ export const commitActionForFile = file => {
return 'update'; return 'update';
}; };
export const getCommitFiles = (stagedFiles, deleteTree = false) => export const getCommitFiles = stagedFiles =>
stagedFiles.reduce((acc, file) => { stagedFiles.reduce((acc, file) => {
if ((file.deleted || deleteTree) && file.type === 'tree') { if (file.moved) return acc;
return acc.concat(getCommitFiles(file.tree, true));
}
return acc.concat({ return acc.concat({
...file, ...file,
deleted: deleteTree || file.deleted,
}); });
}, []); }, []);
...@@ -134,9 +136,10 @@ export const createCommitPayload = ({ branch, getters, newBranch, state, rootSta ...@@ -134,9 +136,10 @@ export const createCommitPayload = ({ branch, getters, newBranch, state, rootSta
actions: getCommitFiles(rootState.stagedFiles).map(f => ({ actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: commitActionForFile(f), action: commitActionForFile(f),
file_path: f.path, file_path: f.path,
content: f.content, previous_path: f.prevPath === '' ? undefined : f.prevPath,
content: f.content || undefined,
encoding: f.base64 ? 'base64' : 'text', encoding: f.base64 ? 'base64' : 'text',
last_commit_id: newBranch || f.deleted ? undefined : f.lastCommitSha, last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
})), })),
start_branch: newBranch ? rootState.currentBranchId : undefined, start_branch: newBranch ? rootState.currentBranchId : undefined,
}); });
...@@ -164,8 +167,7 @@ export const sortTree = sortedTree => ...@@ -164,8 +167,7 @@ export const sortTree = sortedTree =>
) )
.sort(sortTreesByTypeAndName); .sort(sortTreesByTypeAndName);
export const filePathMatches = (f, path) => export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`) === 0;
f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0;
export const getChangesCountForFiles = (files, path) => export const getChangesCountForFiles = (files, path) =>
files.filter(f => filePathMatches(f, path)).length; files.filter(f => filePathMatches(f.path, path)).length;
<script> <script>
import $ from 'jquery';
const buttonVariants = ['danger', 'primary', 'success', 'warning']; const buttonVariants = ['danger', 'primary', 'success', 'warning'];
const sizeVariants = ['sm', 'md', 'lg', 'xl']; const sizeVariants = ['sm', 'md', 'lg', 'xl'];
...@@ -38,6 +40,12 @@ export default { ...@@ -38,6 +40,12 @@ export default {
return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`; return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
}, },
}, },
mounted() {
$(this.$el).on('shown.bs.modal', this.opened).on('hidden.bs.modal', this.closed);
},
beforeDestroy() {
$(this.$el).off('shown.bs.modal', this.opened).off('hidden.bs.modal', this.closed);
},
methods: { methods: {
emitCancel(event) { emitCancel(event) {
this.$emit('cancel', event); this.$emit('cancel', event);
...@@ -45,10 +53,11 @@ export default { ...@@ -45,10 +53,11 @@ export default {
emitSubmit(event) { emitSubmit(event) {
this.$emit('submit', event); this.$emit('submit', event);
}, },
opened({ propertyName }) { opened() {
if (propertyName === 'opacity') {
this.$emit('open'); this.$emit('open');
} },
closed() {
this.$emit('closed');
}, },
}, },
}; };
...@@ -60,7 +69,6 @@ export default { ...@@ -60,7 +69,6 @@ export default {
class="modal fade" class="modal fade"
tabindex="-1" tabindex="-1"
role="dialog" role="dialog"
@transitionend="opened"
> >
<div <div
:class="modalSizeClass" :class="modalSizeClass"
......
...@@ -1377,6 +1377,7 @@ ...@@ -1377,6 +1377,7 @@
.ide-entry-dropdown-toggle { .ide-entry-dropdown-toggle {
padding: $gl-padding-4; padding: $gl-padding-4;
color: $gl-text-color;
background-color: $theme-gray-100; background-color: $theme-gray-100;
&:hover { &:hover {
...@@ -1389,6 +1390,10 @@ ...@@ -1389,6 +1390,10 @@
background-color: $blue-500; background-color: $blue-500;
outline: 0; outline: 0;
} }
svg {
fill: currentColor;
}
} }
.ide-new-btn .dropdown.show .ide-entry-dropdown-toggle { .ide-new-btn .dropdown.show .ide-entry-dropdown-toggle {
......
---
title: Enable renaming files and folders in Web IDE
merge_request: 20835
author:
type: added
...@@ -4378,6 +4378,15 @@ msgstr "" ...@@ -4378,6 +4378,15 @@ msgstr ""
msgid "Remove project" msgid "Remove project"
msgstr "" msgstr ""
msgid "Rename"
msgstr ""
msgid "Rename file"
msgstr ""
msgid "Rename folder"
msgstr ""
msgid "Reply to this email directly or %{view_it_on_gitlab}." msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr "" msgstr ""
......
...@@ -75,7 +75,7 @@ describe('new dropdown component', () => { ...@@ -75,7 +75,7 @@ describe('new dropdown component', () => {
it('calls delete action', () => { it('calls delete action', () => {
spyOn(vm, 'deleteEntry'); spyOn(vm, 'deleteEntry');
vm.$el.querySelectorAll('.dropdown-menu button')[3].click(); vm.$el.querySelectorAll('.dropdown-menu button')[4].click();
expect(vm.deleteEntry).toHaveBeenCalledWith(''); expect(vm.deleteEntry).toHaveBeenCalledWith('');
}); });
......
...@@ -15,7 +15,7 @@ describe('new file modal component', () => { ...@@ -15,7 +15,7 @@ describe('new file modal component', () => {
describe(type, () => { describe(type, () => {
beforeEach(() => { beforeEach(() => {
const store = createStore(); const store = createStore();
store.state.newEntryModal = { store.state.entryModal = {
type, type,
path: '', path: '',
}; };
...@@ -45,7 +45,7 @@ describe('new file modal component', () => { ...@@ -45,7 +45,7 @@ describe('new file modal component', () => {
it('$emits create', () => { it('$emits create', () => {
spyOn(vm, 'createTempEntry'); spyOn(vm, 'createTempEntry');
vm.createEntryInStore(); vm.submitForm();
expect(vm.createTempEntry).toHaveBeenCalledWith({ expect(vm.createTempEntry).toHaveBeenCalledWith({
name: 'testing', name: 'testing',
...@@ -55,4 +55,47 @@ describe('new file modal component', () => { ...@@ -55,4 +55,47 @@ describe('new file modal component', () => {
}); });
}); });
}); });
describe('rename entry', () => {
beforeEach(() => {
const store = createStore();
store.state.entryModal = {
type: 'rename',
path: '',
entry: {
name: 'test',
type: 'blob',
},
};
vm = createComponentWithStore(Component, store).$mount();
});
['tree', 'blob'].forEach(type => {
it(`renders title and button for renaming ${type}`, done => {
const text = type === 'tree' ? 'folder' : 'file';
vm.$store.state.entryModal.entry.type = type;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Rename ${text}`);
expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Rename ${text}`);
done();
});
});
});
describe('entryName', () => {
it('returns entries name', () => {
expect(vm.entryName).toBe('test');
});
it('updated name', () => {
vm.name = 'index.js';
expect(vm.entryName).toBe('index.js');
});
});
});
}); });
...@@ -8,6 +8,7 @@ import actions, { ...@@ -8,6 +8,7 @@ import actions, {
updateTempFlagForEntry, updateTempFlagForEntry,
setErrorMessage, setErrorMessage,
deleteEntry, deleteEntry,
renameEntry,
} from '~/ide/stores/actions'; } from '~/ide/stores/actions';
import store from '~/ide/stores'; import store from '~/ide/stores';
import * as types from '~/ide/stores/mutation_types'; import * as types from '~/ide/stores/mutation_types';
...@@ -468,7 +469,61 @@ describe('Multi-file store actions', () => { ...@@ -468,7 +469,61 @@ describe('Multi-file store actions', () => {
'path', 'path',
store.state, store.state,
[{ type: types.DELETE_ENTRY, payload: 'path' }], [{ type: types.DELETE_ENTRY, payload: 'path' }],
[{ type: 'burstUnusedSeal' }, { type: 'closeFile', payload: store.state.entries.path }], [{ type: 'burstUnusedSeal' }],
done,
);
});
});
describe('renameEntry', () => {
it('renames entry', done => {
store.state.entries.test = {
tree: [],
};
testAction(
renameEntry,
{ path: 'test', name: 'new-name' },
store.state,
[
{
type: types.RENAME_ENTRY,
payload: { path: 'test', name: 'new-name', entryPath: null },
},
],
[{ type: 'deleteEntry', payload: 'test' }],
done,
);
});
it('renames all entries in tree', done => {
store.state.entries.test = {
type: 'tree',
tree: [
{
path: 'tree-1',
},
{
path: 'tree-2',
},
],
};
testAction(
renameEntry,
{ path: 'test', name: 'new-name' },
store.state,
[
{
type: types.RENAME_ENTRY,
payload: { path: 'test', name: 'new-name', entryPath: null },
},
],
[
{ type: 'renameEntry', payload: { path: 'test', name: 'new-name', entryPath: 'tree-1' } },
{ type: 'renameEntry', payload: { path: 'test', name: 'new-name', entryPath: 'tree-2' } },
{ type: 'deleteEntry', payload: 'test' },
],
done, done,
); );
}); });
......
...@@ -294,9 +294,10 @@ describe('IDE commit module actions', () => { ...@@ -294,9 +294,10 @@ describe('IDE commit module actions', () => {
{ {
action: 'update', action: 'update',
file_path: jasmine.anything(), file_path: jasmine.anything(),
content: jasmine.anything(), content: undefined,
encoding: jasmine.anything(), encoding: jasmine.anything(),
last_commit_id: undefined, last_commit_id: undefined,
previous_path: undefined,
}, },
], ],
start_branch: 'master', start_branch: 'master',
...@@ -320,9 +321,10 @@ describe('IDE commit module actions', () => { ...@@ -320,9 +321,10 @@ describe('IDE commit module actions', () => {
{ {
action: 'update', action: 'update',
file_path: jasmine.anything(), file_path: jasmine.anything(),
content: jasmine.anything(), content: undefined,
encoding: jasmine.anything(), encoding: jasmine.anything(),
last_commit_id: '123456789', last_commit_id: '123456789',
previous_path: undefined,
}, },
], ],
start_branch: undefined, start_branch: undefined,
......
...@@ -206,6 +206,7 @@ describe('Multi-file store mutations', () => { ...@@ -206,6 +206,7 @@ describe('Multi-file store mutations', () => {
it('adds to changedFiles', () => { it('adds to changedFiles', () => {
localState.entries.filePath = { localState.entries.filePath = {
deleted: false, deleted: false,
type: 'blob',
}; };
mutations.DELETE_ENTRY(localState, 'filePath'); mutations.DELETE_ENTRY(localState, 'filePath');
...@@ -213,4 +214,103 @@ describe('Multi-file store mutations', () => { ...@@ -213,4 +214,103 @@ describe('Multi-file store mutations', () => {
expect(localState.changedFiles).toEqual([localState.entries.filePath]); expect(localState.changedFiles).toEqual([localState.entries.filePath]);
}); });
}); });
describe('UPDATE_FILE_AFTER_COMMIT', () => {
it('updates URLs if prevPath is set', () => {
const f = {
...file(),
path: 'test',
prevPath: 'testing-123',
rawPath: `${gl.TEST_HOST}/testing-123`,
permalink: `${gl.TEST_HOST}/testing-123`,
commitsPath: `${gl.TEST_HOST}/testing-123`,
blamePath: `${gl.TEST_HOST}/testing-123`,
};
localState.entries.test = f;
localState.changedFiles.push(f);
mutations.UPDATE_FILE_AFTER_COMMIT(localState, { file: f, lastCommit: { commit: {} } });
expect(f.rawPath).toBe(`${gl.TEST_HOST}/test`);
expect(f.permalink).toBe(`${gl.TEST_HOST}/test`);
expect(f.commitsPath).toBe(`${gl.TEST_HOST}/test`);
expect(f.blamePath).toBe(`${gl.TEST_HOST}/test`);
});
});
describe('OPEN_NEW_ENTRY_MODAL', () => {
it('sets entryModal', () => {
localState.entries.testPath = {
...file(),
};
mutations.OPEN_NEW_ENTRY_MODAL(localState, { type: 'test', path: 'testPath' });
expect(localState.entryModal).toEqual({
type: 'test',
path: 'testPath',
entry: localState.entries.testPath,
});
});
});
describe('RENAME_ENTRY', () => {
beforeEach(() => {
localState.trees = {
'gitlab-ce/master': { tree: [] },
};
localState.currentProjectId = 'gitlab-ce';
localState.currentBranchId = 'master';
localState.entries.oldPath = {
...file(),
type: 'blob',
name: 'oldPath',
path: 'oldPath',
url: `${gl.TEST_HOST}/oldPath`,
};
});
it('creates new renamed entry', () => {
mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' });
expect(localState.entries.newPath).toEqual({
...localState.entries.oldPath,
id: 'newPath',
name: 'newPath',
key: 'newPath-blob-name',
path: 'newPath',
tempFile: true,
prevPath: 'oldPath',
tree: [],
parentPath: '',
url: `${gl.TEST_HOST}/newPath`,
moved: jasmine.anything(),
movedPath: jasmine.anything(),
});
});
it('adds new entry to changedFiles', () => {
mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' });
expect(localState.changedFiles.length).toBe(1);
expect(localState.changedFiles[0].path).toBe('newPath');
});
it('sets oldEntry as moved', () => {
mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' });
expect(localState.entries.oldPath.moved).toBe(true);
});
it('adds to parents tree', () => {
localState.entries.oldPath.parentPath = 'parentPath';
localState.entries.parentPath = {
...file(),
};
mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' });
expect(localState.entries.parentPath.tree.length).toBe(1);
});
});
}); });
...@@ -112,6 +112,7 @@ describe('Multi-file store utils', () => { ...@@ -112,6 +112,7 @@ describe('Multi-file store utils', () => {
content: 'updated file content', content: 'updated file content',
encoding: 'text', encoding: 'text',
last_commit_id: '123456789', last_commit_id: '123456789',
previous_path: undefined,
}, },
{ {
action: 'create', action: 'create',
...@@ -119,13 +120,15 @@ describe('Multi-file store utils', () => { ...@@ -119,13 +120,15 @@ describe('Multi-file store utils', () => {
content: 'new file content', content: 'new file content',
encoding: 'base64', encoding: 'base64',
last_commit_id: '123456789', last_commit_id: '123456789',
previous_path: undefined,
}, },
{ {
action: 'delete', action: 'delete',
file_path: 'deletedFile', file_path: 'deletedFile',
content: '', content: undefined,
encoding: 'text', encoding: 'text',
last_commit_id: undefined, last_commit_id: undefined,
previous_path: undefined,
}, },
], ],
start_branch: undefined, start_branch: undefined,
...@@ -172,6 +175,7 @@ describe('Multi-file store utils', () => { ...@@ -172,6 +175,7 @@ describe('Multi-file store utils', () => {
content: 'updated file content', content: 'updated file content',
encoding: 'text', encoding: 'text',
last_commit_id: '123456789', last_commit_id: '123456789',
previous_path: undefined,
}, },
{ {
action: 'create', action: 'create',
...@@ -179,6 +183,7 @@ describe('Multi-file store utils', () => { ...@@ -179,6 +183,7 @@ describe('Multi-file store utils', () => {
content: 'new file content', content: 'new file content',
encoding: 'base64', encoding: 'base64',
last_commit_id: '123456789', last_commit_id: '123456789',
previous_path: undefined,
}, },
], ],
start_branch: undefined, start_branch: undefined,
...@@ -195,33 +200,27 @@ describe('Multi-file store utils', () => { ...@@ -195,33 +200,27 @@ describe('Multi-file store utils', () => {
expect(utils.commitActionForFile({ tempFile: true })).toBe('create'); expect(utils.commitActionForFile({ tempFile: true })).toBe('create');
}); });
it('returns move for moved file', () => {
expect(utils.commitActionForFile({ prevPath: 'test' })).toBe('move');
});
it('returns update by default', () => { it('returns update by default', () => {
expect(utils.commitActionForFile({})).toBe('update'); expect(utils.commitActionForFile({})).toBe('update');
}); });
}); });
describe('getCommitFiles', () => { describe('getCommitFiles', () => {
it('returns flattened list of files and folders', () => { it('returns list of files excluding moved files', () => {
const files = [ const files = [
{ {
path: 'a', path: 'a',
type: 'blob', type: 'blob',
deleted: true, deleted: true,
}, },
{
path: 'b',
type: 'tree',
deleted: true,
tree: [
{ {
path: 'c', path: 'c',
type: 'blob', type: 'blob',
}, moved: true,
{
path: 'd',
type: 'blob',
},
],
}, },
]; ];
...@@ -233,16 +232,6 @@ describe('Multi-file store utils', () => { ...@@ -233,16 +232,6 @@ describe('Multi-file store utils', () => {
type: 'blob', type: 'blob',
deleted: true, deleted: true,
}, },
{
path: 'c',
type: 'blob',
deleted: true,
},
{
path: 'd',
type: 'blob',
deleted: true,
},
]); ]);
}); });
}); });
......
...@@ -29,7 +29,7 @@ describe('GlModal', () => { ...@@ -29,7 +29,7 @@ describe('GlModal', () => {
describe('without id', () => { describe('without id', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(modalComponent, { }); vm = mountComponent(modalComponent, {});
}); });
it('does not add an id attribute to the modal', () => { it('does not add an id attribute to the modal', () => {
...@@ -83,7 +83,7 @@ describe('GlModal', () => { ...@@ -83,7 +83,7 @@ describe('GlModal', () => {
}); });
}); });
it('works with data-toggle="modal"', (done) => { it('works with data-toggle="modal"', done => {
setFixtures(` setFixtures(`
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
<div id="modal-container"></div> <div id="modal-container"></div>
...@@ -91,9 +91,13 @@ describe('GlModal', () => { ...@@ -91,9 +91,13 @@ describe('GlModal', () => {
const modalContainer = document.getElementById('modal-container'); const modalContainer = document.getElementById('modal-container');
const modalButton = document.getElementById('modal-button'); const modalButton = document.getElementById('modal-button');
vm = mountComponent(modalComponent, { vm = mountComponent(
modalComponent,
{
id: 'my-modal', id: 'my-modal',
}, modalContainer); },
modalContainer,
);
$(vm.$el).on('shown.bs.modal', () => done()); $(vm.$el).on('shown.bs.modal', () => done());
modalButton.click(); modalButton.click();
...@@ -103,7 +107,7 @@ describe('GlModal', () => { ...@@ -103,7 +107,7 @@ describe('GlModal', () => {
const dummyEvent = 'not really an event'; const dummyEvent = 'not really an event';
beforeEach(() => { beforeEach(() => {
vm = mountComponent(modalComponent, { }); vm = mountComponent(modalComponent, {});
spyOn(vm, '$emit'); spyOn(vm, '$emit');
}); });
...@@ -122,11 +126,27 @@ describe('GlModal', () => { ...@@ -122,11 +126,27 @@ describe('GlModal', () => {
expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent); expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent);
}); });
}); });
describe('opened', () => {
it('emits a open event', () => {
vm.opened();
expect(vm.$emit).toHaveBeenCalledWith('open');
});
});
describe('closed', () => {
it('emits a closed event', () => {
vm.closed();
expect(vm.$emit).toHaveBeenCalledWith('closed');
});
});
}); });
describe('slots', () => { describe('slots', () => {
const slotContent = 'this should go into the slot'; const slotContent = 'this should go into the slot';
const modalWithSlot = (slotName) => { const modalWithSlot = slotName => {
let template; let template;
if (slotName) { if (slotName) {
template = ` template = `
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment