feat: improved conflict resolution when uploading/copying/moving files (#5765)

This commit is contained in:
Ariel Leyva
2026-02-27 08:55:49 -05:00
committed by GitHub
parent e3d00d591b
commit aa809096eb
13 changed files with 550 additions and 154 deletions

View File

@@ -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) => {

View File

@@ -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);
},
},
};

View File

@@ -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);
},
},
};

View File

@@ -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(() => {

View File

@@ -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>

View 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>

View File

@@ -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);
}
},
});