diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-08-03 13:00:48 +0300 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-08-03 13:00:48 +0300 |
commit | b901df220c9411eba4f541ac91b3e275a0dfa1df (patch) | |
tree | 5a295fe884ed9a2199eba6dadd089739e5f149be /app | |
parent | 972854078bb9d02f857ed95bea1cc3c98c039d2e (diff) | |
parent | 19eecd01fada302afe814a485172d699c96d44e8 (diff) |
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
Diffstat (limited to 'app')
17 files changed, 243 insertions, 55 deletions
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 8eddfa5e455..f02fd6cf7ea 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue'; import newModal from './modal.vue'; import upload from './upload.vue'; import ItemButton from './button.vue'; +import { modalTypes } from '../../constants'; export default { components: { @@ -56,6 +57,7 @@ export default { this.dropdownOpen = !this.dropdownOpen; }, }, + modalTypes, }; </script> @@ -74,7 +76,7 @@ export default { @click.stop="openDropdown()" > <icon - name="hamburger" + name="ellipsis_v" /> <icon name="arrow-down" @@ -106,13 +108,22 @@ export default { class="d-flex" icon="folder-new" icon-classes="mr-2" - @click="createNewItem('tree')" + @click="createNewItem($options.modalTypes.tree)" /> </li> <li class="divider"></li> </template> <li> <item-button + :label="__('Rename')" + class="d-flex" + icon="pencil" + icon-classes="mr-2" + @click="createNewItem($options.modalTypes.rename)" + /> + </li> + <li> + <item-button :label="__('Delete')" class="d-flex" icon="remove" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 833c4b027df..e500ef0e1b5 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -2,6 +2,7 @@ import { __ } from '~/locale'; import { mapActions, mapState } from 'vuex'; import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { modalTypes } from '../../constants'; export default { components: { @@ -13,42 +14,58 @@ export default { }; }, computed: { - ...mapState(['newEntryModal']), + ...mapState(['entryModal']), entryName: { 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) { this.name = val; }, }, modalTitle() { - if (this.newEntryModal.type === 'tree') { + if (this.entryModal.type === modalTypes.tree) { 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'); }, buttonLabel() { - if (this.newEntryModal.type === 'tree') { + if (this.entryModal.type === modalTypes.tree) { return __('Create directory'); + } else if (this.entryModal.type === modalTypes.rename) { + return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } return __('Create file'); }, }, methods: { - ...mapActions(['createTempEntry']), - createEntryInStore() { - this.createTempEntry({ - name: this.name, - type: this.newEntryModal.type, - }); + ...mapActions(['createTempEntry', 'renameEntry']), + submitForm() { + if (this.entryModal.type === modalTypes.rename) { + this.renameEntry({ + path: this.entryModal.entry.path, + name: this.entryName, + }); + } else { + this.createTempEntry({ + name: this.name, + type: this.entryModal.type, + }); + } }, focusInput() { - setTimeout(() => { - this.$refs.fieldName.focus(); - }); + this.$refs.fieldName.focus(); + }, + closedModal() { + this.name = ''; }, }, }; @@ -60,8 +77,9 @@ export default { :header-title-text="modalTitle" :footer-primary-button-text="buttonLabel" footer-primary-button-variant="success" - @submit="createEntryInStore" + @submit="submitForm" @open="focusInput" + @closed="closedModal" > <div class="form-group row" diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index eb4a927fe0d..dbdf0be2809 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -134,8 +134,7 @@ export default { .replace(/[/]$/g, ''); // - strip ending "/" - const filePath = this.file.path - .replace(/[/]$/g, ''); + const filePath = this.file.path.replace(/[/]$/g, ''); return filePath === routePath; }, @@ -194,7 +193,7 @@ export default { data-container="body" data-placement="right" name="file-modified" - css-classes="prepend-left-5 multi-file-modified" + css-classes="prepend-left-5 ide-file-modified" /> </span> <changed-file-icon @@ -208,7 +207,6 @@ export default { </span> <new-dropdown :type="file.type" - :branch="file.branchId" :path="file.path" :mouse-over="mouseOver" class="float-right prepend-left-8" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 0b514f31467..d3ac57471c9 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -53,3 +53,8 @@ export const commitItemIconMap = { class: 'ide-file-deletion', }, }; + +export const modalTypes = { + rename: 'rename', + tree: 'tree', +}; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index cb93fba1665..f0193d8e8ea 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -8,7 +8,7 @@ export default { }); }, getRawFileData(file) { - if (file.tempFile) { + if (file.tempFile && !file.prevPath) { return Promise.resolve(file.content); } diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 2765acada48..aa02dfbddc4 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -186,13 +186,39 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => { }; export const deleteEntry = ({ commit, dispatch, state }, path) => { - dispatch('burstUnusedSeal'); - dispatch('closeFile', state.entries[path]); + const entry = 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); + + 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 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/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index b343750f789..9e3f5da4676 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -62,14 +62,14 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive = true }) => { 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 }); + const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url; + return service - .getFileData( - `${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`, - ) + .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${url.replace('/-/', '/')}`) .then(({ data, headers }) => { const normalizedHeaders = normalizeHeaders(headers); setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); @@ -101,7 +101,7 @@ export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) = service .getRawFileData(file) .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) { service .getBaseRawFileData(file, baseSha) @@ -176,9 +176,22 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => { export const discardFileChanges = ({ dispatch, state, commit, getters }, 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.REMOVE_FILE_FROM_CHANGED, path); + if (file.prevPath) { + dispatch('discardFileChanges', file.prevPath); + } + if (file.tempFile && file.opened) { commit(types.TOGGLE_FILE_OPEN, path); } else if (getters.activeFile && file.path === getters.activeFile.path) { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index acb6ef5e6d4..9288bbe32f5 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -89,3 +89,13 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = 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); + } +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 5ce268b0d05..79cdb494e5a 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -67,9 +67,9 @@ export const someUncommitedChanges = state => !!(state.changedFiles.length || state.stagedFiles.length); 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( - f => filePathMatches(f, path) && !getChangedFile(state)(f.path), + f => filePathMatches(f.path, path) && !getChangedFile(state)(f.path), ).length; return changedFilesCount + stagedFilesCount; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index dae60f4d65a..5a7991d2fa7 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -77,3 +77,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const DELETE_ENTRY = 'DELETE_ENTRY'; +export const RENAME_ENTRY = 'RENAME_ENTRY'; + +export const RESTORE_TREE = 'RESTORE_TREE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 799c2f51e8d..d0bf847dbde 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -131,11 +131,14 @@ export default { }, [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) { const changedFile = state.changedFiles.find(f => f.path === file.path); + const { prevPath } = file; Object.assign(state.entries[file.path], { raw: file.content, changed: !!changedFile, staged: false, + prevPath: '', + moved: false, lastCommit: Object.assign(state.entries[file.path].lastCommit, { id: lastCommit.commit.id, url: lastCommit.commit_path, @@ -144,6 +147,18 @@ export default { 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) { Object.assign(state, { @@ -169,7 +184,11 @@ export default { }, [types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) { Object.assign(state, { - newEntryModal: { type, path }, + entryModal: { + type, + path, + entry: { ...state.entries[path] }, + }, }); }, [types.DELETE_ENTRY](state, path) { @@ -179,8 +198,48 @@ export default { : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; entry.deleted = true; - state.changedFiles = state.changedFiles.concat(entry); + 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, ...mergeRequestMutation, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 9a87d50d6d5..c75add39bcd 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -53,15 +53,25 @@ export default { }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { 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), ); - Object.assign(state.entries[file.path], { - raw, - }); + if (file.tempFile) { + Object.assign(state.entries[file.path], { + content: raw, + }); + } else { + Object.assign(state.entries[file.path], { + raw, + }); + } - if (openPendingFile) { + if (!openPendingFile) return; + + if (!openPendingFile.tempFile) { openPendingFile.raw = raw; + } else if (openPendingFile.tempFile) { + openPendingFile.content = raw; } }, [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { @@ -119,12 +129,14 @@ export default { [types.DISCARD_FILE_CHANGES](state, path) { const stagedFile = state.stagedFiles.find(f => f.path === path); const entry = state.entries[path]; - const { deleted } = entry; + const { deleted, prevPath } = entry; Object.assign(state.entries[path], { content: stagedFile ? stagedFile.content : state.entries[path].raw, changed: false, deleted: false, + moved: false, + movedPath: '', }); if (deleted) { @@ -133,6 +145,12 @@ export default { : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; 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) { diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 2cf34af9274..eac7441ee54 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -1,4 +1,5 @@ import * as types from '../mutation_types'; +import { sortTree } from '../utils'; export default { [types.TOGGLE_TREE_OPEN](state, path) { @@ -36,4 +37,14 @@ export default { 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)); + } + }, }; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0f32a267469..2371b201f8c 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -26,8 +26,9 @@ export default () => ({ rightPane: null, links: {}, errorMessage: null, - newEntryModal: { + entryModal: { type: '', path: '', + entry: {}, }, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index bf7ab93ff5e..0ede76fd1e0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -47,6 +47,9 @@ export const dataStructure = () => ({ lastOpenedAt: 0, mrChange: null, deleted: false, + prevPath: '', + movedPath: '', + moved: false, }); export const decorateData = entity => { @@ -107,7 +110,9 @@ export const setPageTitle = title => { }; export const commitActionForFile = file => { - if (file.deleted) { + if (file.prevPath) { + return 'move'; + } else if (file.deleted) { return 'delete'; } else if (file.tempFile) { return 'create'; @@ -116,15 +121,12 @@ export const commitActionForFile = file => { return 'update'; }; -export const getCommitFiles = (stagedFiles, deleteTree = false) => +export const getCommitFiles = stagedFiles => stagedFiles.reduce((acc, file) => { - if ((file.deleted || deleteTree) && file.type === 'tree') { - return acc.concat(getCommitFiles(file.tree, true)); - } + if (file.moved) return acc; return acc.concat({ ...file, - deleted: deleteTree || file.deleted, }); }, []); @@ -134,9 +136,10 @@ export const createCommitPayload = ({ branch, getters, newBranch, state, rootSta actions: getCommitFiles(rootState.stagedFiles).map(f => ({ action: commitActionForFile(f), file_path: f.path, - content: f.content, + previous_path: f.prevPath === '' ? undefined : f.prevPath, + content: f.content || undefined, 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, }); @@ -164,8 +167,7 @@ export const sortTree = sortedTree => ) .sort(sortTreesByTypeAndName); -export const filePathMatches = (f, path) => - f.path.replace(new RegExp(`${f.name}$`), '').indexOf(`${path}/`) === 0; +export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`) === 0; export const getChangesCountForFiles = (files, path) => - files.filter(f => filePathMatches(f, path)).length; + files.filter(f => filePathMatches(f.path, path)).length; diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index 416eda796a7..b023c5cfeb1 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -1,4 +1,6 @@ <script> +import $ from 'jquery'; + const buttonVariants = ['danger', 'primary', 'success', 'warning']; const sizeVariants = ['sm', 'md', 'lg', 'xl']; @@ -38,6 +40,12 @@ export default { 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: { emitCancel(event) { this.$emit('cancel', event); @@ -45,10 +53,11 @@ export default { emitSubmit(event) { this.$emit('submit', event); }, - opened({ propertyName }) { - if (propertyName === 'opacity') { - this.$emit('open'); - } + opened() { + this.$emit('open'); + }, + closed() { + this.$emit('closed'); }, }, }; @@ -60,7 +69,6 @@ export default { class="modal fade" tabindex="-1" role="dialog" - @transitionend="opened" > <div :class="modalSizeClass" diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 442aef124d3..58ed5bf6455 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -1377,6 +1377,7 @@ .ide-entry-dropdown-toggle { padding: $gl-padding-4; + color: $gl-text-color; background-color: $theme-gray-100; &:hover { @@ -1389,6 +1390,10 @@ background-color: $blue-500; outline: 0; } + + svg { + fill: currentColor; + } } .ide-new-btn .dropdown.show .ide-entry-dropdown-toggle { |