feat: improved conflict resolution when uploading/copying/moving files (#5765)
This commit is contained in:
@@ -178,6 +178,10 @@ const drop = async (event: Event) => {
|
||||
from: fileStore.req?.items[i].url,
|
||||
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
|
||||
name: fileStore.req?.items[i].name,
|
||||
size: fileStore.req?.items[i].size,
|
||||
modified: fileStore.req?.items[i].modified,
|
||||
overwrite: false,
|
||||
rename: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -189,7 +193,7 @@ const drop = async (event: Event) => {
|
||||
const path = el.__vue__.url;
|
||||
const baseItems = (await api.fetch(path)).items;
|
||||
|
||||
const action = (overwrite: boolean, rename: boolean) => {
|
||||
const action = (overwrite?: boolean, rename?: boolean) => {
|
||||
api
|
||||
.move(items, overwrite, rename)
|
||||
.then(() => {
|
||||
@@ -200,26 +204,35 @@ const drop = async (event: Event) => {
|
||||
|
||||
const conflict = upload.checkConflict(items, baseItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event: Event, option: any) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
prompt: "resolve-conflict",
|
||||
props: {
|
||||
conflict: conflict,
|
||||
},
|
||||
confirm: (event: Event, result: Array<ConflictingResource>) => {
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
action(overwrite, rename);
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const item = result[i];
|
||||
if (item.checked.length == 2) {
|
||||
items[item.index].rename = true;
|
||||
} else if (item.checked.length == 1 && item.checked[0] == "origin") {
|
||||
items[item.index].overwrite = true;
|
||||
} else {
|
||||
items.splice(item.index, 1);
|
||||
}
|
||||
}
|
||||
if (items.length > 0) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
action(false, false);
|
||||
};
|
||||
|
||||
const itemClick = (event: Event | KeyboardEvent) => {
|
||||
|
||||
@@ -91,6 +91,10 @@ export default {
|
||||
from: this.req.items[item].url,
|
||||
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||
name: this.req.items[item].name,
|
||||
size: this.req.items[item].size,
|
||||
modified: this.req.items[item].modified,
|
||||
overwrite: false,
|
||||
rename: this.$route.path === this.dest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,36 +122,41 @@ export default {
|
||||
});
|
||||
};
|
||||
|
||||
if (this.$route.path === this.dest) {
|
||||
this.closeHovers();
|
||||
action(false, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dstItems = (await api.fetch(this.dest)).items;
|
||||
const conflict = upload.checkConflict(items, dstItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
if (conflict.length > 0) {
|
||||
this.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event, option) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
prompt: "resolve-conflict",
|
||||
props: {
|
||||
conflict: conflict,
|
||||
},
|
||||
confirm: (event, result) => {
|
||||
event.preventDefault();
|
||||
this.closeHovers();
|
||||
action(overwrite, rename);
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const item = result[i];
|
||||
if (item.checked.length == 2) {
|
||||
items[item.index].rename = true;
|
||||
} else if (
|
||||
item.checked.length == 1 &&
|
||||
item.checked[0] == "origin"
|
||||
) {
|
||||
items[item.index].overwrite = true;
|
||||
} else {
|
||||
items.splice(item.index, 1);
|
||||
}
|
||||
}
|
||||
if (items.length > 0) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
action(false, false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,6 +97,10 @@ export default {
|
||||
from: this.req.items[item].url,
|
||||
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||
name: this.req.items[item].name,
|
||||
size: this.req.items[item].size,
|
||||
modified: this.req.items[item].modified,
|
||||
overwrite: false,
|
||||
rename: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,26 +125,39 @@ export default {
|
||||
const dstItems = (await api.fetch(this.dest)).items;
|
||||
const conflict = upload.checkConflict(items, dstItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
if (conflict.length > 0) {
|
||||
this.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event, option) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
prompt: "resolve-conflict",
|
||||
props: {
|
||||
conflict: conflict,
|
||||
files: items,
|
||||
},
|
||||
confirm: (event, result) => {
|
||||
event.preventDefault();
|
||||
this.closeHovers();
|
||||
action(overwrite, rename);
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const item = result[i];
|
||||
if (item.checked.length == 2) {
|
||||
items[item.index].rename = true;
|
||||
} else if (
|
||||
item.checked.length == 1 &&
|
||||
item.checked[0] == "origin"
|
||||
) {
|
||||
items[item.index].overwrite = true;
|
||||
} else {
|
||||
items.splice(item.index, 1);
|
||||
}
|
||||
}
|
||||
if (items.length > 0) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
action(false, false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,11 +23,11 @@ import Copy from "./Copy.vue";
|
||||
import NewFile from "./NewFile.vue";
|
||||
import NewDir from "./NewDir.vue";
|
||||
import Replace from "./Replace.vue";
|
||||
import ReplaceRename from "./ReplaceRename.vue";
|
||||
import Share from "./Share.vue";
|
||||
import ShareDelete from "./ShareDelete.vue";
|
||||
import Upload from "./Upload.vue";
|
||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||
import ResolveConflict from "./ResolveConflict.vue";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
@@ -44,12 +44,12 @@ const components = new Map<string, any>([
|
||||
["newDir", NewDir],
|
||||
["download", Download],
|
||||
["replace", Replace],
|
||||
["replace-rename", ReplaceRename],
|
||||
["share", Share],
|
||||
["upload", Upload],
|
||||
["share-delete", ShareDelete],
|
||||
["deleteUser", DeleteUser],
|
||||
["discardEditorChanges", DiscardEditorChanges],
|
||||
["resolve-conflict", ResolveConflict],
|
||||
]);
|
||||
|
||||
const modal = computed(() => {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.replace") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.replaceMessage") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat button--blue"
|
||||
@click="(event) => currentPrompt.confirm(event, 'rename')"
|
||||
:aria-label="$t('buttons.rename')"
|
||||
:title="$t('buttons.rename')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.rename") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--red"
|
||||
@click="(event) => currentPrompt.confirm(event, 'overwrite')"
|
||||
:aria-label="$t('buttons.replace')"
|
||||
:title="$t('buttons.replace')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.replace") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "replace-rename",
|
||||
computed: {
|
||||
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
307
frontend/src/components/prompts/ResolveConflict.vue
Normal file
307
frontend/src/components/prompts/ResolveConflict.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>
|
||||
{{
|
||||
personalized
|
||||
? $t("prompts.resolveConflict")
|
||||
: $t("prompts.replaceOrSkip")
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<template v-if="personalized">
|
||||
<p v-if="isUploadAction != true">
|
||||
{{ $t("prompts.singleConflictResolve") }}
|
||||
</p>
|
||||
<div class="conflict-list-container">
|
||||
<div>
|
||||
<p>
|
||||
<input
|
||||
@change="toogleCheckAll"
|
||||
type="checkbox"
|
||||
:checked="originAllChecked"
|
||||
value="origin"
|
||||
/>
|
||||
{{
|
||||
isUploadAction != true
|
||||
? $t("prompts.filesInOrigin")
|
||||
: $t("prompts.uploadingFiles")
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
@change="toogleCheckAll"
|
||||
type="checkbox"
|
||||
:checked="destAllChecked"
|
||||
value="dest"
|
||||
/>
|
||||
{{ $t("prompts.filesInDest") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<template v-for="(item, index) in conflict" :key="index">
|
||||
<div class="conflict-file-name">
|
||||
<span>{{ item.name }}</span>
|
||||
|
||||
<template v-if="item.checked.length == 2">
|
||||
<span v-if="isUploadAction != true" class="result-rename">
|
||||
{{ $t("prompts.rename") }}
|
||||
</span>
|
||||
<span v-else class="result-error">
|
||||
{{ $t("prompts.forbiddenError") }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="
|
||||
item.checked.length == 1 && item.checked[0] == 'origin'
|
||||
"
|
||||
class="result-override"
|
||||
>
|
||||
{{ $t("prompts.override") }}
|
||||
</span>
|
||||
<span v-else class="result-skip">
|
||||
{{ $t("prompts.skip") }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="item.checked" type="checkbox" value="origin" />
|
||||
<div>
|
||||
<p class="conflict-file-value">
|
||||
{{ humanTime(item.origin.lastModified) }}
|
||||
</p>
|
||||
<p class="conflict-file-value">
|
||||
{{ humanSize(item.origin.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="item.checked" type="checkbox" value="dest" />
|
||||
<div>
|
||||
<p class="conflict-file-value">
|
||||
{{ humanTime(item.dest.lastModified) }}
|
||||
</p>
|
||||
<p class="conflict-file-value">
|
||||
{{ humanSize(item.dest.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ $t("prompts.fastConflictResolve", { count: conflict.length }) }}
|
||||
</p>
|
||||
|
||||
<div class="result-buttons">
|
||||
<button @click="(e) => resolve(e, ['origin'])">
|
||||
<i class="material-icons">done_all</i>
|
||||
{{ $t("buttons.overrideAll") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isUploadAction != true"
|
||||
@click="(e) => resolve(e, ['origin', 'dest'])"
|
||||
>
|
||||
<i class="material-icons">folder_copy</i>
|
||||
{{ $t("buttons.renameAll") }}
|
||||
</button>
|
||||
<button @click="(e) => resolve(e, ['dest'])">
|
||||
<i class="material-icons">undo</i>
|
||||
{{ $t("buttons.skipAll") }}
|
||||
</button>
|
||||
<button @click="personalized = true">
|
||||
<i class="material-icons">checklist</i>
|
||||
{{ $t("buttons.singleDecision") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card-action" style="display: flex; justify-content: end">
|
||||
<div>
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="close"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="4"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="personalized"
|
||||
id="focus-prompt"
|
||||
class="button button--flat"
|
||||
@click="(event) => currentPrompt?.confirm(event, conflict)"
|
||||
:aria-label="$t('buttons.ok')"
|
||||
:title="$t('buttons.ok')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.ok") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { filesize } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const { currentPrompt } = layoutStore;
|
||||
|
||||
const conflict = ref<ConflictingResource[]>(currentPrompt?.props.conflict);
|
||||
|
||||
const isUploadAction = ref<boolean | undefined>(
|
||||
currentPrompt?.props.isUploadAction
|
||||
);
|
||||
|
||||
const personalized = ref(false);
|
||||
|
||||
const originAllChecked = computed(() => {
|
||||
for (const item of conflict.value) {
|
||||
if (!item.checked.includes("origin")) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const destAllChecked = computed(() => {
|
||||
for (const item of conflict.value) {
|
||||
if (!item.checked.includes("dest")) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
layoutStore.closeHovers();
|
||||
};
|
||||
|
||||
const humanSize = (size: number | undefined) => {
|
||||
return size == undefined ? "Unknown size" : filesize(size);
|
||||
};
|
||||
|
||||
const humanTime = (modified: string | number | undefined) => {
|
||||
if (modified == undefined) return "Unknown date";
|
||||
|
||||
return dayjs(modified).format("L LT");
|
||||
};
|
||||
|
||||
const resolve = (event: Event, result: Array<"origin" | "dest">) => {
|
||||
for (const item of conflict.value) {
|
||||
item.checked = result;
|
||||
}
|
||||
currentPrompt?.confirm(event, conflict.value);
|
||||
};
|
||||
|
||||
const toogleCheckAll = (e: Event) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
const value = target.value as "origin" | "dest" | "both";
|
||||
const checked = target.checked;
|
||||
|
||||
for (const item of conflict.value) {
|
||||
if (value == "both") {
|
||||
item.checked = ["origin", "dest"];
|
||||
} else {
|
||||
if (!item.checked.includes(value)) {
|
||||
if (checked) {
|
||||
item.checked.push(value);
|
||||
}
|
||||
} else {
|
||||
if (!checked) {
|
||||
item.checked = value == "dest" ? ["origin"] : ["dest"];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.conflict-list-container {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.conflict-list-container > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: solid 1px var(--textPrimary);
|
||||
gap: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.conflict-list-container > div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.conflict-list-container > div > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.conflict-file-name {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--textPrimary);
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.conflict-file-value {
|
||||
color: var(--textPrimary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-rename,
|
||||
.result-override,
|
||||
.result-error,
|
||||
.result-skip {
|
||||
font-size: 0.75rem;
|
||||
line-height: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.result-override {
|
||||
background-color: var(--input-green);
|
||||
}
|
||||
|
||||
.result-error {
|
||||
background-color: var(--icon-red);
|
||||
}
|
||||
.result-rename {
|
||||
background-color: var(--icon-orange);
|
||||
}
|
||||
.result-skip {
|
||||
background-color: var(--icon-blue);
|
||||
}
|
||||
|
||||
.result-buttons > button {
|
||||
padding: 0.75rem;
|
||||
color: var(--textPrimary);
|
||||
margin: 0.25rem 0;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: transparent;
|
||||
border: solid 1px transparent;
|
||||
width: 100%;
|
||||
transition: all ease-in-out 200ms;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.result-buttons > button:hover {
|
||||
border: solid 1px var(--icon-blue);
|
||||
}
|
||||
</style>
|
||||
@@ -69,18 +69,29 @@ const uploadInput = (event: Event) => {
|
||||
const path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
|
||||
|
||||
if (conflict) {
|
||||
if (conflict.length > 0) {
|
||||
layoutStore.showHover({
|
||||
prompt: "replace",
|
||||
action: (event: Event) => {
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
upload.handleFiles(uploadFiles, path, false);
|
||||
prompt: "resolve-conflict",
|
||||
props: {
|
||||
conflict: conflict,
|
||||
isUploadAction: true,
|
||||
},
|
||||
confirm: (event: Event) => {
|
||||
confirm: (event: Event, result: Array<ConflictingResource>) => {
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
upload.handleFiles(uploadFiles, path, true);
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const item = result[i];
|
||||
if (item.checked.length == 2) {
|
||||
continue;
|
||||
} else if (item.checked.length == 1 && item.checked[0] == "origin") {
|
||||
uploadFiles[item.index].overwrite = true;
|
||||
} else {
|
||||
uploadFiles.splice(item.index, 1);
|
||||
}
|
||||
}
|
||||
if (uploadFiles.length > 0) {
|
||||
upload.handleFiles(uploadFiles, path);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user