Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 99 additions & 46 deletions lib/components/ConflictPicker/ConflictPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import type { INode } from '@nextcloud/files'
import type { ConflictInput, ConflictResolutionResult } from '../../conflict-picker.ts'

import { mdiArrowRight, mdiClose } from '@mdi/js'
import { mdiArrowRight } from '@mdi/js'
import { basename } from '@nextcloud/paths'
import { computed, shallowRef, useTemplateRef } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
Expand All @@ -25,9 +26,9 @@ const props = defineProps<{
container?: string | undefined

/**
* Directory/context file name
* Directory with the conflicts, its name is shown in the description
*/
dirname: string
dirname?: string

/**
* The existing nodes (same names as the conflicts)
Expand Down Expand Up @@ -59,7 +60,10 @@ const blockedTitle = t('You need to select at least one version of each file to
const formElement = useTemplateRef('form')
const conflictEntries = useTemplateRef('conflictEntry')

const newSelected = shallowRef<ConflictInput[]>([])
const isSingle = computed(() => props.incoming.length === 1)

// Preselect the new files so the user can continue right away.
const newSelected = shallowRef<ConflictInput[]>([...props.incoming])
const oldSelected = shallowRef<ConflictInput[]>([])

const isNoNewSelected = computed(() => newSelected.value.length === 0)
Expand All @@ -77,9 +81,21 @@ const areConflictsResolved = computed(() => {
return true
})

const dialogName = computed(() => props.dirname?.trim() !== ''
? n('%n file conflict in {dirname}', '%n file conflicts in {dirname}', props.incoming.length, { dirname: props.dirname })
: n('%n file conflict', '%n files conflict', props.incoming.length))
const dialogName = computed(() => isSingle.value
? t('Select file to keep')
: t('Select files to keep'))

// Only the folder name, the root folder is called "All files" in the Files app
const folderName = computed(() => basename(props.dirname ?? '') || t('All files'))

// Split on the placeholder so the folder name can be shown in bold
const descriptionParts = computed(() => {
const message = isSingle.value
? t('An item with the same name already exists in {dirname}.')
: t('Items with the same name already exist in {dirname}, select which to keep.')
const [before, after = ''] = message.split('{dirname}')
return { before, after }
})

/**
* Cancel the conflic resolution (closing the dialog)
Expand All @@ -88,6 +104,28 @@ function onCancel() {
emit('close', null)
}

/**
* Single-file: replace the existing file with the new one.
*/
function onReplace() {
emit('close', {
selected: [...props.incoming],
renamed: [],
skipped: [],
})
}

/**
* Single-file: keep both, the new file is uploaded with a renamed copy.
*/
function onKeepBoth() {
emit('close', {
selected: [],
renamed: [...props.incoming],
skipped: [],
})
}

/**
* Skip all conflicts (no upload will be performed)
*/
Expand Down Expand Up @@ -185,8 +223,10 @@ function onSubmit() {
<div :class="$style.pickerHeader">
<!-- Description -->
<p id="conflict-picker-description" :class="$style.pickerDescription">
{{ t('Which files do you want to keep?') }}<br>
{{ t('If you select both versions, the incoming file will have a number added to its name.') }}<br>
{{ descriptionParts.before }}<strong>{{ folderName }}</strong>{{ descriptionParts.after }}<br>
<template v-if="!isSingle">
{{ t('Files and folders not selected will be deleted. If both are chosen, they will be renamed.') }}<br>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing, what does "will be deleted" mean?
In which case are files deleted?

</template>
<template v-if="recursiveUpload">
{{ t('When an incoming folder is selected, the content is written into the existing folder and a recursive conflict resolution is performed.') }}
</template>
Expand All @@ -205,22 +245,23 @@ function onSubmit() {
aria-labelledby="conflict-picker-description"
:class="$style.pickerForm"
@submit.prevent.stop="onSubmit">
<!-- Select all checkboxes -->
<fieldset :class="$style.pickerSelectAll">
<!-- Column headings / select all checkboxes (only useful with multiple files) -->
<fieldset v-if="!isSingle" :class="$style.pickerSelectAll">
<legend class="hidden-visually">
{{ t('Select all checkboxes') }}
</legend>
<NcCheckboxRadioSwitch
:modelValue="areAllNewSelected"
:indeterminate="areSomeNewSelected"
@update:modelValue="onSelectAllNew">
{{ t('Select all new files') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:modelValue="areAllOldSelected"
:indeterminate="areSomeOldSelected"
@update:modelValue="onSelectAllOld">
{{ t('Select all existing files') }}
{{ t('Existing files') }}
</NcCheckboxRadioSwitch>
<span :class="$style.pickerSelectAllSpacer" />
<NcCheckboxRadioSwitch
:modelValue="areAllNewSelected"
:indeterminate="areSomeNewSelected"
@update:modelValue="onSelectAllNew">
{{ t('New files') }}
</NcCheckboxRadioSwitch>
</fieldset>

Expand All @@ -229,6 +270,7 @@ function onSubmit() {
v-for="(node, index) in existing"
ref="conflictEntry"
:key="node.fileid"
:single="isSingle"
:incoming="incoming[index]!"
:existing="node"
:incomingSelected="newSelected.includes(incoming[index]!)"
Expand All @@ -245,39 +287,46 @@ function onSubmit() {
data-cy-conflict-picker-cancel
variant="tertiary"
@click="onCancel">
<template #icon>
<NcIconSvgWrapper :path="mdiClose" />
</template>
{{ t('Cancel') }}
</NcButton>

<!-- Align right -->
<span :class="$style.pickerActionSeparator" />

<NcButton @click="onSkipAll">
<template #icon>
<NcIconSvgWrapper :path="mdiClose" />
</template>
{{ incoming.length === 1 ? t('Skip this file') : n('Skip %n file', 'Skip %n files', incoming.length) }}
</NcButton>
<NcButton
:aria-disabled="!areConflictsResolved"
:class="[
$style.pickerActionSubmit,
{
[$style.pickerActionSubmit_disabled]: !areConflictsResolved,
},
]"
:title="areConflictsResolved ? '' : blockedTitle"
type="submit"
variant="primary"
@click.stop.prevent="onSubmit">
<template #icon>
<NcIconSvgWrapper directional :path="mdiArrowRight" />
</template>
{{ t('Continue') }}
<span v-if="!areConflictsResolved" class="hidden-visually">{{ blockedTitle }}</span>
</NcButton>
<!-- Single file: keep both or replace -->
<template v-if="isSingle">
<NcButton variant="secondary" @click="onKeepBoth">
{{ t('Keep both') }}
</NcButton>
<NcButton variant="primary" @click="onReplace">
{{ t('Replace') }}
</NcButton>
</template>

<!-- Multiple files: skip all or continue with the selection -->
<template v-else>
<NcButton @click="onSkipAll">
{{ n('Skip %n file', 'Skip %n files', incoming.length) }}
</NcButton>
<NcButton
:aria-disabled="!areConflictsResolved"
:class="[
$style.pickerActionSubmit,
{
[$style.pickerActionSubmit_disabled]: !areConflictsResolved,
},
]"
:title="areConflictsResolved ? '' : blockedTitle"
type="submit"
variant="primary"
@click.stop.prevent="onSubmit">
<template #icon>
<NcIconSvgWrapper directional :path="mdiArrowRight" />
</template>
{{ t('Continue') }}
<span v-if="!areConflictsResolved" class="hidden-visually">{{ blockedTitle }}</span>
</NcButton>
</template>
</template>
</NcDialog>
</template>
Expand All @@ -286,6 +335,8 @@ function onSubmit() {
.picker {
--margin: 36px;
--secondary-margin: 18px;
// shared width of the middle arrow column, so headings and entries line up
--arrow-column: 44px;
}

.pickerHeader {
Expand Down Expand Up @@ -315,11 +366,13 @@ function onSubmit() {
width: 100%;
margin-top: calc(var(--secondary-margin) * 1.5);
padding-bottom: var(--secondary-margin);
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr var(--arrow-column) 1fr;
align-items: center;

legend {
display: flex;
align-items: center;
grid-column: 1 / -1;
width: 100%;
margin-bottom: calc(var(--secondary-margin) / 2);
}
Expand Down
142 changes: 142 additions & 0 deletions lib/components/ConflictPicker/ConflictPickerCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors

- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import { mdiFile, mdiFolder } from '@mdi/js'
import { formatFileSize } from '@nextcloud/files'
import { computed, ref, watch } from 'vue'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { t } from '../../utils/l10n.ts'

const props = defineProps<{
/**
* Preview URL, if available
*/
preview?: string

/**
* Modification time, if available
*/
mtime?: Date

/**
* File size in bytes, if available
*/
size?: number

/**
* Whether the node is a folder (changes the fallback icon)
*/
isFolder: boolean

/**
* Visually hidden label describing this side ("Existing version"/"New version").
* Kept for screen readers as the column heading already labels it visually.
*/
label: string

/**
* Bold the modification time (it is the more recent one)
*/
boldDate?: boolean

/**
* Bold the size (it is the larger one)
*/
boldSize?: boolean
}>()

// Previews can 404, fall back to the icon instead of a broken image
const previewFailed = ref(false)
watch(() => props.preview, () => {
previewFailed.value = false
})
const showPreview = computed(() => !!props.preview && !previewFailed.value)
</script>

<template>
<span :class="$style.card">
<!-- Icon or preview -->
<NcIconSvgWrapper
v-if="!showPreview"
:class="[$style.cardIcon, { [$style.cardIcon_folder]: isFolder }]"
:path="isFolder ? mdiFolder : mdiFile"
:size="48" />
<img
v-else
:class="$style.cardPreview"
:src="preview"
alt=""
loading="lazy"
@error="previewFailed = true">

<!-- Description -->
<span :class="$style.cardDescription">
<NcDateTime
v-if="mtime"
:class="{ [$style.bold]: boldDate }"
:timestamp="mtime"
:relativeTime="false"
:format="{ timeStyle: 'short', dateStyle: 'medium' }" />
<span v-else>
{{ t('Last modified date unknown') }}
</span>
<span v-if="size !== undefined" :class="{ [$style.bold]: boldSize }">
{{ formatFileSize(size) }}
</span>
</span>

<span class="hidden-visually">{{ label }}</span>
</span>
</template>

<style module lang="scss">
$height: 64px;

.card {
display: flex;
align-items: center;
height: $height;
}

.cardIcon,
.cardPreview {
height: $height;
width: $height;
margin: 0 var(--secondary-margin);
display: block;
flex: 0 0 $height;
}

.cardIcon {
color: var(--color-text-maxcontrast);
}

.cardIcon_folder {
color: var(--color-primary-element);
}

.cardPreview {
overflow: hidden;
border-radius: calc(var(--border-radius) * 2);
object-fit: cover;
}

.cardDescription {
display: flex;
flex-direction: column;
min-width: 0;

span,
:deep(time) {
white-space: nowrap;
}
}

.bold {
font-weight: bold;
}
</style>
Loading
Loading