fix: Fix conflict modal and add a resume transfert option (#5884)

Co-authored-by: Henrique Dias <mail@hacdias.com>
This commit is contained in:
Anthony Geourjon 2026-05-05 14:24:06 +02:00 committed by GitHub
parent 1f22fe65ec
commit f4e148523e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 311 additions and 72 deletions

View file

@ -49,6 +49,12 @@ export async function fetch(url: string, signal?: AbortSignal) {
return data;
}
export async function fetchAll(url: string): Promise<RecursiveEntry[]> {
url = removePrefix(url);
const res = await fetchURL(`/api/resources/recursive${url}`, {});
return (await res.json()) as RecursiveEntry[];
}
async function resourceAction(url: string, method: ApiMethod, content?: any) {
url = removePrefix(url);

View file

@ -191,7 +191,6 @@ const drop = async (event: Event) => {
return;
}
const path = el.__vue__.url;
const baseItems = (await api.fetch(path)).items;
const action = (overwrite?: boolean, rename?: boolean) => {
const action =
@ -205,7 +204,7 @@ const drop = async (event: Event) => {
.catch($showError);
};
const conflict = upload.checkConflict(items, baseItems);
const conflict = await upload.checkConflict(items, path);
if (conflict.length > 0) {
layoutStore.showHover({

View file

@ -122,8 +122,7 @@ export default {
});
};
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
const conflict = upload.checkConflict(items, this.dest);
if (conflict.length > 0) {
this.showHover({

View file

@ -122,8 +122,7 @@ export default {
});
};
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
const conflict = upload.checkConflict(items, this.dest);
if (conflict.length > 0) {
this.showHover({

View file

@ -112,6 +112,16 @@
<i class="material-icons">undo</i>
{{ $t("buttons.skipAll") }}
</button>
<button @click="(e) => resume(e)">
<i class="material-icons">replay</i>
{{ $t("buttons.resumeTransfer") }}
<span class="info-tooltip" @click.stop="() => {}">
<i class="material-icons info-icon">info_outline</i>
<span class="info-tooltip-text">
{{ $t("buttons.resumeTransferTooltip") }}
</span>
</span>
</button>
<button @click="personalized = true">
<i class="material-icons">checklist</i>
{{ $t("buttons.singleDecision") }}
@ -194,6 +204,17 @@ const humanTime = (modified: string | number | undefined) => {
return dayjs(modified).format("L LT");
};
const resume = (event: Event) => {
conflict.value.forEach((item) => {
if (item.isSmallerOnServer) {
item.checked = ["origin"];
} else {
item.checked = ["dest"];
}
});
currentPrompt?.confirm(event, conflict.value);
};
const resolve = (event: Event, result: Array<"origin" | "dest">) => {
for (const item of conflict.value) {
item.checked = result;
@ -304,4 +325,42 @@ const toogleCheckAll = (e: Event) => {
.result-buttons > button:hover {
border: solid 1px var(--icon-blue);
}
.info-tooltip {
position: relative;
display: inline-flex;
align-items: center;
}
.info-icon {
font-size: 1rem;
color: var(--icon-blue);
cursor: help;
}
.info-tooltip-text {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: var(--surfacePrimary, #333);
color: var(--textPrimary, #fff);
font-size: 0.75rem;
line-height: 1.3;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
width: 220px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: opacity 200ms ease-in-out;
z-index: 10;
pointer-events: none;
}
.info-tooltip:hover .info-tooltip-text {
visibility: visible;
opacity: 1;
}
</style>

View file

@ -35,7 +35,6 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import * as upload from "@/utils/upload";
@ -43,11 +42,10 @@ import * as upload from "@/utils/upload";
const { t } = useI18n();
const route = useRoute();
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
// TODO: this is a copy of the same function in FileListing.vue
const uploadInput = (event: Event) => {
const uploadInput = async (event: Event) => {
const files = (event.currentTarget as HTMLInputElement)?.files;
if (files === null) return;
@ -67,7 +65,8 @@ const uploadInput = (event: Event) => {
}
const path = route.path.endsWith("/") ? route.path : route.path + "/";
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
const conflict = await upload.checkConflict(uploadFiles, path);
if (conflict.length > 0) {
layoutStore.showHover({

View file

@ -29,6 +29,8 @@
"rename": "Rename",
"replace": "Replace",
"reportIssue": "Report Issue",
"resumeTransfer": "Resume previous transfert",
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
"save": "Save",
"schedule": "Schedule",
"search": "Search",

View file

@ -29,6 +29,8 @@
"rename": "Renommer",
"replace": "Remplacer",
"reportIssue": "Signaler un problème",
"resumeTransfer": "Reprendre un transfert précedement interrompu",
"resumeTransferTooltip": "Ignorer tous les fichiers en conflit, sauf ceux qui sont plus petits sur le serveur, car nous supposons que leur transfert a été interrompu.",
"save": "Enregistrer",
"schedule": "Planifier",
"search": "Rechercher",

View file

@ -71,10 +71,19 @@ interface ConflictingResource {
name: string;
origin: ConflictingItem;
dest: ConflictingItem;
checked: Array<"origin" | "dest">;
checked: Array<"origin" | "dest", "origin-resume">;
isSmallerOnServer?: boolean;
}
interface CsvData {
headers: string[];
rows: string[][];
}
interface RecursiveEntry {
path: string;
name: string;
size: number;
modified: string;
isDir: boolean;
}

View file

@ -16,6 +16,7 @@ interface UploadEntry {
size: number;
isDir: boolean;
fullPath?: string;
to?: string;
file?: File;
overwrite?: boolean;
}

View file

@ -12,10 +12,7 @@ import { describe, it, expect } from "vitest";
type Entry = { name: string; isFile: boolean; isDirectory: boolean };
function simulateScanFiles(
dirName: string,
entryBatches: Entry[][]
): string[] {
function simulateScanFiles(dirName: string, entryBatches: Entry[][]): string[] {
const paths: string[] = [];
let batchIndex = 0;
@ -28,7 +25,8 @@ function simulateScanFiles(
// Mirrors readReaderContent() from upload.ts lines 118-138
function readReaderContent(directory: string): void {
const entries = batchIndex < entryBatches.length ? entryBatches[batchIndex] : [];
const entries =
batchIndex < entryBatches.length ? entryBatches[batchIndex] : [];
batchIndex++;
if (entries.length > 0) {
@ -56,9 +54,7 @@ describe("scanFiles path construction", () => {
{ name: "file1.xlsx", isFile: true, isDirectory: false },
{ name: "file2.xlsx", isFile: true, isDirectory: false },
],
[
{ name: "file3.xlsx", isFile: true, isDirectory: false },
],
[{ name: "file3.xlsx", isFile: true, isDirectory: false }],
]);
expect(paths).toHaveLength(3);
@ -70,9 +66,7 @@ describe("scanFiles path construction", () => {
it("single batch should work fine (no regression)", () => {
const paths = simulateScanFiles("TestFolder", [
[
{ name: "file1.xlsx", isFile: true, isDirectory: false },
],
[{ name: "file1.xlsx", isFile: true, isDirectory: false }],
]);
expect(paths).toEqual(["TestFolder/file1.xlsx"]);

View file

@ -1,60 +1,164 @@
import { useLayoutStore } from "@/stores/layout";
import { useUploadStore } from "@/stores/upload";
import url from "@/utils/url";
import { files as api } from "@/api";
import { removePrefix } from "@/api/utils";
export function checkConflict(
files: UploadList | Array<any>,
dest: ResourceItem[]
): ConflictingResource[] {
if (typeof dest === "undefined" || dest === null) {
dest = [];
}
const conflictingFiles: ConflictingResource[] = [];
interface UploadEntryWithChild extends UploadEntry {
children?: UploadEntryWithChild[];
originalIndex: number;
}
const folder_upload = files[0].fullPath !== undefined;
/**
* Convert UploadList into a forest (array of root nodes).
* This properly handles uploads with multiple top-level files/folders
* instead of assuming a single root.
* @param flatArray
* @param basePath
*/
function flatToForest(
flatArray: UploadList,
basePath: string
): UploadEntryWithChild[] {
const nodeMap: Record<string, UploadEntryWithChild> = {};
function getFile(name: string): ResourceItem | null {
for (const item of dest) {
if (item.name == name) return item;
}
// First pass: create all nodes
flatArray.forEach((item, index) => {
// File list is created from very different action and info available are not always the same.
// By doing a drag and drop or upload a folder (both from the browser or from the OS) we have the fullPath property available
// By uploading a single file using the file input, we only have the "name" property
// By doing drag and drop from filebrowser to filebrowser, we have the "to" property available but not the fullPath
const fullPathOrTo =
item.fullPath || item.to?.replace(basePath, "") || item.name;
nodeMap[fullPathOrTo!] = {
fullPath: fullPathOrTo,
isDir: item.isDir,
name: item.name,
size: item.size,
originalIndex: index,
...(item.isDir && { children: [] }),
...(item.file && { file: item.file }),
};
});
return null;
}
const roots: UploadEntryWithChild[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const name = file.name;
// Second pass: build hierarchy
flatArray.forEach((item) => {
// see comment before to explanation
const fullPathOrTo =
item.fullPath || item.to?.replace(basePath, "") || item.name;
if (folder_upload && file.isDir) {
const dirs = file.fullPath?.split("/");
// For folder uploads, destination listing is flat and only contains
// top-level entries. Treating every nested file as a conflict when the
// parent folder exists blocks the whole upload (see #5798), so skip
// preflight conflict detection for nested files.
if (dirs && dirs.length > 1) {
continue;
const node = nodeMap[fullPathOrTo!];
const lastSlash = fullPathOrTo!.lastIndexOf("/");
if (lastSlash === -1) {
roots.push(node);
} else {
const parentPath = fullPathOrTo!.substring(0, lastSlash);
const parent = nodeMap[parentPath];
if (parent?.children) {
parent.children.push(node);
}
}
});
const item = getFile(name);
if (item != null) {
conflictingFiles.push({
index: i,
name: item.path,
origin: {
lastModified: file.modified || file.file?.lastModified,
size: file.size,
},
dest: {
lastModified: item.modified,
size: item.size,
},
checked: ["origin"],
});
return roots;
}
/**
* Return conflict files from
* @param files - flat upload list to check
* @param basePath - server destination path (e.g. "/files/uploads/")
*/
export async function checkConflict(
files: UploadList,
basePath: string
): Promise<ConflictingResource[]> {
console.log(
"Starting conflict check, " + files.length + " possible conflict found."
);
const forest = flatToForest(files, basePath);
if (forest.length === 0) return [];
// Single API call: fetch the entire server tree under basePath.
let serverEntries: RecursiveEntry[] = [];
try {
serverEntries = await api.fetchAll(basePath);
} catch {
console.error(
`Failed to fetch recursive server listing for ${basePath}. ` +
`Assuming directory doesn't exist and skipping conflict check.`
);
return [];
}
// Build a lookup map keyed by the normalised server path for O(1) access.
// The server returns paths relative to the user's scope (e.g. "/uploads/foo.txt").
// We strip the basePath prefix so the key matches the upload entry's fullPath.
const normBase = removePrefix(basePath).replace(/\/+$/, "");
const serverMap = new Map<string, RecursiveEntry>();
for (const entry of serverEntries) {
// entry.path is absolute from server root, e.g. "/uploads/sub/file.txt"
// We need the relative part after normBase, e.g. "sub/file.txt"
let rel = entry.path;
if (rel.startsWith(normBase)) {
rel = rel.slice(normBase.length);
}
// Strip leading slash so it matches fullPath format ("sub/file.txt")
rel = rel.replace(/^\/+/, "");
serverMap.set(rel, entry);
}
const conflicts: ConflictingResource[] = [];
/**
* Walk the upload tree and compare each file node against the
* pre-fetched server map. Directories only need to be recursed
* when they appear in the map (otherwise no child can conflict).
*/
function recursiveCheckConflict(nodes: UploadEntryWithChild[]): void {
for (const node of nodes) {
if (node.isDir && node.children) {
// Only recurse if this directory exists on the server
const dirKey = node.fullPath!.replace(/^\/+/, "");
if (serverMap.has(dirKey)) {
recursiveCheckConflict(node.children);
}
} else {
// File check for a conflict against the server map
const fileKey = node.fullPath!.replace(/^\/+/, "");
const serverEntry = serverMap.get(fileKey);
if (serverEntry) {
conflicts.push({
index: node.originalIndex,
name: serverEntry.path,
origin: {
lastModified: node.file?.lastModified,
size: node.size,
},
dest: {
lastModified: serverEntry.modified,
size: serverEntry.size,
},
checked: ["origin"],
isSmallerOnServer: node.size > serverEntry.size,
});
}
}
}
}
return conflictingFiles;
// Walk all root nodes synchronously against the pre-fetched data
recursiveCheckConflict(forest);
console.log(conflicts.length + " conflicts found.");
conflicts.sort((a, b) => a.index - b.index);
return conflicts;
}
export function scanFiles(dt: DataTransfer): Promise<UploadList | FileList> {

View file

@ -647,7 +647,7 @@ const copyCut = (event: Event | KeyboardEvent): void => {
});
};
const paste = (event: Event) => {
const paste = async (event: Event) => {
if ((event.target as HTMLElement).tagName?.toLowerCase() === "input") return;
// TODO router location should it be
@ -696,7 +696,8 @@ const paste = (event: Event) => {
};
}
const conflict = upload.checkConflict(items, fileStore.req!.items);
const path = route.path.endsWith("/") ? route.path : route.path + "/";
const conflict = await upload.checkConflict(items, path);
if (conflict.length > 0) {
layoutStore.showHover({
@ -799,7 +800,6 @@ const drop = async (event: DragEvent) => {
}
const files: UploadList = (await upload.scanFiles(dt)) as UploadList;
let items = fileStore.req.items;
let path = route.path.endsWith("/") ? route.path : route.path + "/";
if (
@ -812,13 +812,14 @@ const drop = async (event: DragEvent) => {
path = el.__vue__.url;
try {
items = (await api.fetch(path)).items;
(await api.fetch(path)).items;
} catch (error: any) {
$showError(error);
return;
}
}
const conflict = upload.checkConflict(files, items);
const conflict = await upload.checkConflict(files, path);
const preselect = removePrefix(path) + (files[0].fullPath || files[0].name);
@ -856,7 +857,7 @@ const drop = async (event: DragEvent) => {
fileStore.preselect = preselect;
};
const uploadInput = (event: Event) => {
const uploadInput = async (event: Event) => {
const files = (event.currentTarget as HTMLInputElement)?.files;
if (files === null) return;
@ -876,7 +877,7 @@ const uploadInput = (event: Event) => {
}
const path = route.path.endsWith("/") ? route.path : route.path + "/";
const conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
const conflict = await upload.checkConflict(uploadFiles, path);
if (conflict.length > 0) {
layoutStore.showHover({

View file

@ -57,6 +57,7 @@ func NewHandler(
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
api.PathPrefix("/resources/recursive").Handler(monkey(resourceGetRecursiveHandler, "/api/resources/recursive")).Methods("GET")
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
api.PathPrefix("/resources").Handler(monkey(resourcePostHandler(fileCache), "/api/resources")).Methods("POST")

View file

@ -13,6 +13,7 @@ import (
"path"
"path/filepath"
"strings"
"time"
"github.com/shirou/gopsutil/v4/disk"
"github.com/spf13/afero"
@ -376,6 +377,69 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
}
}
// RecursiveEntry is a single file/directory entry returned by the recursive listing endpoint.
type RecursiveEntry struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modified"`
IsDir bool `json:"isDir"`
}
// resourceGetRecursiveHandler returns a flat list of every file and directory
// under the requested path, walking the tree recursively on the server side
// so the client only needs a single HTTP call.
var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
rootPath := r.URL.Path
if rootPath == "" {
rootPath = "/"
}
// Make sure the root itself exists and is a directory.
info, err := d.user.Fs.Stat(rootPath)
if err != nil {
return errToStatus(err), err
}
if !info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
}
entries := make([]RecursiveEntry, 0)
err = afero.Walk(d.user.Fs, rootPath, func(fPath string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip entries we cannot read
}
// Skip the root directory itself.
if fPath == rootPath {
return nil
}
// Respect user rules.
if !d.Check(fPath) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
entries = append(entries, RecursiveEntry{
Path: fPath,
Name: info.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
IsDir: info.IsDir(),
})
return nil
})
if err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, r, entries)
})
type DiskUsageResponse struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`