mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
New feature: allow to reorder items in quick panel
This commit is contained in:
1 parent
dc5c8cb32e
commit
74eeadab83
34 files changed
+1417
-358
No files matched your search
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getNeighborReorderIndex,
|
||||
getRowMidpointsFromHeights,
|
||||
} from '../sortable-geometry';
|
||||
|
||||
describe('getRowMidpointsFromHeights', () => {
|
||||
it('builds midpoints from a list top and row heights', () => {
|
||||
expect(getRowMidpointsFromHeights(10, [20, 30, 10])).toEqual([20, 45, 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNeighborReorderIndex', () => {
|
||||
const midpoints = [10, 30, 50, 70];
|
||||
|
||||
it('moves down only after the pointer crosses the next midpoint', () => {
|
||||
expect(getNeighborReorderIndex(29, 0, midpoints)).toBe(0);
|
||||
expect(getNeighborReorderIndex(31, 0, midpoints)).toBe(1);
|
||||
});
|
||||
|
||||
it('moves up only after the pointer crosses the previous midpoint', () => {
|
||||
expect(getNeighborReorderIndex(51, 2, midpoints)).toBe(2);
|
||||
expect(getNeighborReorderIndex(29, 2, midpoints)).toBe(1);
|
||||
});
|
||||
|
||||
it('stays in place at the ends', () => {
|
||||
expect(getNeighborReorderIndex(0, 0, midpoints)).toBe(0);
|
||||
expect(getNeighborReorderIndex(100, 3, midpoints)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export { default as SortableList } from './sortable-list.vue';
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export function getRowMidpointsFromHeights(listTop: number, heights: number[]): number[] {
|
||||
let top = listTop;
|
||||
|
||||
return heights.map((height) => {
|
||||
const midpoint = top + (height / 2);
|
||||
top += height;
|
||||
return midpoint;
|
||||
});
|
||||
}
|
||||
|
||||
export function getNeighborReorderIndex(
|
||||
pointerY: number,
|
||||
currentIndex: number,
|
||||
rowMidpoints: number[],
|
||||
): number {
|
||||
const nextIndex = currentIndex + 1;
|
||||
const previousIndex = currentIndex - 1;
|
||||
|
||||
if (nextIndex < rowMidpoints.length && pointerY > rowMidpoints[nextIndex]) {
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
if (previousIndex >= 0 && pointerY < rowMidpoints[previousIndex]) {
|
||||
return previousIndex;
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useAttrs,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { applyDropResult, haveSameKeyOrder } from '@/utils/reorder-matching-items';
|
||||
import { getNeighborReorderIndex, getRowMidpointsFromHeights } from './sortable-geometry';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
items: T[];
|
||||
getKey: (item: T) => string;
|
||||
handleSelector?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set': [value: T[]];
|
||||
'drag-start': [];
|
||||
'drag-end': [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
item(props: { item: T }): unknown;
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
const DRAG_START_DISTANCE_PX = 4;
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const localItems = shallowRef<T[]>([...props.items]);
|
||||
const draggingIndex = ref<number | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const ghostX = ref(0);
|
||||
const ghostY = ref(0);
|
||||
const ghostWidth = ref(0);
|
||||
|
||||
let pointerStartY = 0;
|
||||
let grabOffsetX = 0;
|
||||
let grabOffsetY = 0;
|
||||
let lastClientX = 0;
|
||||
let lastClientY = 0;
|
||||
let activePointerId: number | null = null;
|
||||
let rowHeights: number[] = [];
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(items) => {
|
||||
if (draggingIndex.value !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
localItems.value = [...items];
|
||||
},
|
||||
);
|
||||
|
||||
function captureRowHeights() {
|
||||
const listElement = listRef.value;
|
||||
|
||||
if (!listElement) {
|
||||
rowHeights = [];
|
||||
return;
|
||||
}
|
||||
|
||||
rowHeights = Array.from(
|
||||
listElement.querySelectorAll<HTMLElement>(':scope > .sortable-list__items > .sortable-list__item'),
|
||||
row => row.getBoundingClientRect().height,
|
||||
);
|
||||
}
|
||||
|
||||
function getRowMidpoints(): number[] {
|
||||
const listElement = listRef.value;
|
||||
|
||||
if (!listElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getRowMidpointsFromHeights(listElement.getBoundingClientRect().top, rowHeights);
|
||||
}
|
||||
|
||||
function setDocumentDragging(enabled: boolean) {
|
||||
document.documentElement.classList.toggle('sortable-list-dragging', enabled);
|
||||
}
|
||||
|
||||
function moveGhost(clientX: number, clientY: number) {
|
||||
ghostX.value = clientX - grabOffsetX;
|
||||
ghostY.value = clientY - grabOffsetY;
|
||||
}
|
||||
|
||||
function stopPointerTracking() {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
|
||||
function isHandleTarget(eventTarget: EventTarget | null): boolean {
|
||||
if (!props.handleSelector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return eventTarget instanceof Element && eventTarget.closest(props.handleSelector) !== null;
|
||||
}
|
||||
|
||||
function onPointerDown(pointerEvent: PointerEvent, index: number) {
|
||||
if (pointerEvent.button !== 0 || !isHandleTarget(pointerEvent.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = pointerEvent.currentTarget;
|
||||
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerEvent.stopPropagation();
|
||||
|
||||
const handle = props.handleSelector
|
||||
? pointerEvent.target instanceof Element
|
||||
? pointerEvent.target.closest<HTMLElement>(props.handleSelector)
|
||||
: null
|
||||
: row;
|
||||
const grabElement = handle ?? row;
|
||||
const rect = grabElement.getBoundingClientRect();
|
||||
grabOffsetX = pointerEvent.clientX - rect.left;
|
||||
grabOffsetY = pointerEvent.clientY - rect.top;
|
||||
ghostWidth.value = row.getBoundingClientRect().width;
|
||||
pointerStartY = pointerEvent.clientY;
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
draggingIndex.value = index;
|
||||
isDragging.value = false;
|
||||
activePointerId = pointerEvent.pointerId;
|
||||
moveGhost(pointerEvent.clientX, pointerEvent.clientY);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
|
||||
function startDragging() {
|
||||
isDragging.value = true;
|
||||
captureRowHeights();
|
||||
setDocumentDragging(true);
|
||||
emit('drag-start');
|
||||
moveGhost(lastClientX, lastClientY);
|
||||
}
|
||||
|
||||
function onPointerMove(pointerEvent: PointerEvent) {
|
||||
if (draggingIndex.value === null || activePointerId !== pointerEvent.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
|
||||
if (!isDragging.value) {
|
||||
if (Math.abs(pointerEvent.clientY - pointerStartY) < DRAG_START_DISTANCE_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDragging();
|
||||
}
|
||||
|
||||
pointerEvent.preventDefault();
|
||||
moveGhost(pointerEvent.clientX, pointerEvent.clientY);
|
||||
|
||||
const nextIndex = getNeighborReorderIndex(
|
||||
pointerEvent.clientY,
|
||||
draggingIndex.value,
|
||||
getRowMidpoints(),
|
||||
);
|
||||
|
||||
if (nextIndex === draggingIndex.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropResult = {
|
||||
removedIndex: draggingIndex.value,
|
||||
addedIndex: nextIndex,
|
||||
payload: localItems.value[draggingIndex.value],
|
||||
};
|
||||
|
||||
localItems.value = applyDropResult(localItems.value, dropResult);
|
||||
rowHeights = applyDropResult(rowHeights, {
|
||||
...dropResult,
|
||||
payload: rowHeights[draggingIndex.value],
|
||||
});
|
||||
draggingIndex.value = nextIndex;
|
||||
}
|
||||
|
||||
function onPointerUp(pointerEvent: PointerEvent) {
|
||||
if (activePointerId !== pointerEvent.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopPointerTracking();
|
||||
setDocumentDragging(false);
|
||||
|
||||
const hadDrag = isDragging.value;
|
||||
const nextItems = localItems.value;
|
||||
|
||||
draggingIndex.value = null;
|
||||
isDragging.value = false;
|
||||
activePointerId = null;
|
||||
rowHeights = [];
|
||||
|
||||
if (!hadDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!haveSameKeyOrder(props.items, nextItems, props.getKey)) {
|
||||
emit('set', nextItems);
|
||||
}
|
||||
|
||||
emit('drag-end');
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPointerTracking();
|
||||
setDocumentDragging(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="sortable-list"
|
||||
:class="[attrs.class, { 'sortable-list--dragging': isDragging }]"
|
||||
>
|
||||
<TransitionGroup
|
||||
class="sortable-list__items"
|
||||
name="sortable-list"
|
||||
tag="div"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in localItems"
|
||||
:key="props.getKey(item)"
|
||||
class="sortable-list__item"
|
||||
:class="{ 'sortable-list__item--placeholder': index === draggingIndex && isDragging }"
|
||||
@pointerdown="onPointerDown($event, index)"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggingIndex !== null"
|
||||
class="sortable-list__ghost"
|
||||
:style="{
|
||||
top: `${ghostY}px`,
|
||||
left: `${ghostX}px`,
|
||||
width: `${ghostWidth}px`,
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="localItems[draggingIndex]"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sortable-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sortable-list--dragging {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sortable-list__item {
|
||||
width: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sortable-list__item--placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-list-move {
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.sortable-list__ghost {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: hsl(var(--popover));
|
||||
box-shadow: 0 4px 12px hsl(var(--background) / 80%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.sortable-list-dragging,
|
||||
html.sortable-list-dragging * {
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "文件已在网络上共享。当前共享将被停止并替换为新的共享。",
|
||||
"replaceShareDialogConfirm": "替换"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Es werden bereits Dateien im Netzwerk freigegeben. Die aktuelle Freigabe wird gestoppt und durch eine neue ersetzt.",
|
||||
"replaceShareDialogConfirm": "Ersetzen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Ya se están compartiendo archivos en la red. El recurso compartido actual se detendrá y será reemplazado por uno nuevo.",
|
||||
"replaceShareDialogConfirm": "Reemplazar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "فایلها از قبل از طریق شبکه به اشتراک گذاشته شدهاند. اشتراکگذاری فعلی متوقف و با مورد جدید جایگزین خواهد شد.",
|
||||
"replaceShareDialogConfirm": "جایگزینی"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Des fichiers sont déjà partagés sur le réseau. Le partage actuel sera arrêté et remplacé par le nouveau.",
|
||||
"replaceShareDialogConfirm": "Remplacer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "קבצים כבר משותפים ברשת. השיתוף הנוכחי ייעצר ויוחלף בחדש.",
|
||||
"replaceShareDialogConfirm": "החלף"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "फ़ाइलें पहले से ही नेटवर्क पर साझा की जा रही हैं. वर्तमान शेयर बंद कर दिया जाएगा और उसके स्थान पर नया शेयर ले लिया जाएगा।",
|
||||
"replaceShareDialogConfirm": "प्रतिस्थापित करें"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Ֆայլերն արդեն կիսվում են ցանցում: Ընթացիկ կիսումը կդադարեցվի և կփոխարինվի նորով:",
|
||||
"replaceShareDialogConfirm": "Փոխարինել"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Ci sono già file condivisi in rete. La condivisione attuale verrà interrotta e sostituita con quella nuova.",
|
||||
"replaceShareDialogConfirm": "Sostituisci"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "すでにネットワーク上でファイルが共有されています。現在の共有を停止し、新しいものに置き換えます。",
|
||||
"replaceShareDialogConfirm": "置き換え"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Já existem arquivos sendo compartilhados na rede. O compartilhamento atual será interrompido e substituído pelo novo.",
|
||||
"replaceShareDialogConfirm": "Substituir"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Файлы уже доступны по сети. Текущий доступ будет остановлен и заменён новым.",
|
||||
"replaceShareDialogConfirm": "Заменить"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Datoteke se že delijo po omrežju. Trenutno deljenje bo ustavljeno in zamenjano z novim.",
|
||||
"replaceShareDialogConfirm": "Zamenjaj"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Ağ üzerinden zaten dosyalar paylaşılıyor. Mevcut paylaşım durdurulup yenisiyle değiştirilecek.",
|
||||
"replaceShareDialogConfirm": "Değiştir"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "فائلیں پہلے سے نیٹ ورک پر ہیں۔ موجودہ شیئر بند ہو کر نئے سے بدل جائے گا۔",
|
||||
"replaceShareDialogConfirm": "بدل دیں"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,4 +1260,4 @@
|
||||
"replaceShareDialogDescription": "Các tập tin đang được chia sẻ qua mạng. Chia sẻ hiện tại sẽ bị dừng và thay thế bằng chia sẻ mới.",
|
||||
"replaceShareDialogConfirm": "Thay thế"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QuickAccessItemIcon from './quick-access-item-icon.vue';
|
||||
import { DirEntryInteractive } from '@/components/dir-entry-interactive';
|
||||
import { getPathDisplayName } from '@/utils/normalize-path';
|
||||
|
||||
const props = defineProps<{
|
||||
path: string;
|
||||
isFile: boolean;
|
||||
isCurrentDirectoryContext: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const itemName = computed(() => {
|
||||
if (!props.path) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getPathDisplayName(props.path, t) || props.path;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirEntryInteractive
|
||||
:path="props.path"
|
||||
:is-file="props.isFile"
|
||||
:is-current-directory-context="props.isCurrentDirectoryContext"
|
||||
>
|
||||
<div
|
||||
class="quick-access-panel__item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('open')"
|
||||
@keydown.enter.prevent="emit('open')"
|
||||
@keydown.space.prevent="emit('open')"
|
||||
>
|
||||
<QuickAccessItemIcon
|
||||
:path="props.path"
|
||||
:is-file="props.isFile"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="quick-access-panel__item-name">{{ itemName }}</span>
|
||||
</div>
|
||||
</DirEntryInteractive>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-access-panel__item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
text-align: start;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-access-panel__item:hover {
|
||||
background-color: hsl(var(--foreground) / 5%);
|
||||
}
|
||||
|
||||
.quick-access-panel__item-name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.dir-entry-interactive[data-drag-over] > .quick-access-panel__item {
|
||||
background-color: var(--drop-target-background);
|
||||
outline: var(--drop-target-outline);
|
||||
outline-offset: var(--drop-target-outline-offset);
|
||||
}
|
||||
</style>
|
||||
@@ -14,33 +14,32 @@ const tagsOpenState = ref<boolean | null>(null);
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
} from '@lucide/vue';
|
||||
import QuickAccessItemIcon from './quick-access-item-icon.vue';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { StarIcon, TagIcon } from '@lucide/vue';
|
||||
import QuickAccessEntry from './quick-access-entry.vue';
|
||||
import QuickAccessSection from './quick-access-section.vue';
|
||||
import { SortableList } from '@/components/sortable-list';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { DirEntryInteractive } from '@/components/dir-entry-interactive';
|
||||
import { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { useUserStatsStore } from '@/stores/storage/user-stats';
|
||||
import { useWorkspacesStore } from '@/stores/storage/workspaces';
|
||||
import { registerDropContainer, unregisterDropContainer } from '@/composables/use-drop-target-registry';
|
||||
import type { QuickAccessSectionId } from '@/types/user-settings';
|
||||
import type { FavoriteItem, ItemTag, TaggedItem } from '@/types/user-stats';
|
||||
import { getPathDisplayName } from '@/utils/normalize-path';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-locations';
|
||||
import { openNavigatorNavigablePath } from '@/utils/open-navigator-directory';
|
||||
import { arePathsEquivalent } from '@/utils/file-operation-paths';
|
||||
import { haveSameKeyOrder, reorderMatchingItems } from '@/utils/reorder-matching-items';
|
||||
import { normalizeQuickAccessSectionOrder } from '../utils/quick-access-section-order';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const userSettingsStore = useUserSettingsStore();
|
||||
const userStatsStore = useUserStatsStore();
|
||||
const workspacesStore = useWorkspacesStore();
|
||||
const emit = defineEmits<{
|
||||
'drag-start': [];
|
||||
'drag-end': [];
|
||||
}>();
|
||||
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
let dropContainerId: number | null = null;
|
||||
@@ -95,18 +94,21 @@ const hasTaggedSectionContent = computed(() => {
|
||||
|
||||
const favoritesOpen = computed({
|
||||
get: () => favoritesOpenState.value ?? favoriteItems.value.length > 0,
|
||||
set: (value: boolean) => { favoritesOpenState.value = value; },
|
||||
set: (value: boolean) => {
|
||||
favoritesOpenState.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
const tagsOpen = computed({
|
||||
get: () => tagsOpenState.value ?? totalTaggedItemCount.value > 0,
|
||||
set: (value: boolean) => { tagsOpenState.value = value; },
|
||||
set: (value: boolean) => {
|
||||
tagsOpenState.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
function getItemName(path: string): string {
|
||||
if (!path) return '';
|
||||
return getPathDisplayName(path, t) || path;
|
||||
}
|
||||
const sectionOrder = computed(() => {
|
||||
return normalizeQuickAccessSectionOrder(userSettingsStore.userSettings.quickAccessSectionOrder);
|
||||
});
|
||||
|
||||
function isFavoriteFile(item: FavoriteItem): boolean {
|
||||
if (isVirtualLocationPath(item.path)) {
|
||||
@@ -132,6 +134,68 @@ function openFavoriteItem(item: FavoriteItem) {
|
||||
function openTaggedItem(item: TaggedItem) {
|
||||
openItem(item.path, item.isFile);
|
||||
}
|
||||
|
||||
function getSectionKey(sectionId: QuickAccessSectionId): string {
|
||||
return sectionId;
|
||||
}
|
||||
|
||||
function getItemKey(item: FavoriteItem | TaggedItem): string {
|
||||
return item.path;
|
||||
}
|
||||
|
||||
function getFavoriteEntryProps(item: FavoriteItem) {
|
||||
const isFile = isFavoriteFile(item);
|
||||
|
||||
return {
|
||||
path: item.path,
|
||||
isFile,
|
||||
isCurrentDirectoryContext: isCurrentDirectoryItem(item.path, isFile),
|
||||
};
|
||||
}
|
||||
|
||||
function getTaggedEntryProps(item: TaggedItem) {
|
||||
return {
|
||||
path: item.path,
|
||||
isFile: item.isFile,
|
||||
isCurrentDirectoryContext: isCurrentDirectoryItem(item.path, item.isFile),
|
||||
};
|
||||
}
|
||||
|
||||
function handleItemDragEnd() {
|
||||
function swallowClick(clickEvent: MouseEvent) {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
window.removeEventListener('click', swallowClick, true);
|
||||
}
|
||||
|
||||
window.addEventListener('click', swallowClick, true);
|
||||
window.setTimeout(() => {
|
||||
window.removeEventListener('click', swallowClick, true);
|
||||
}, 0);
|
||||
emit('drag-end');
|
||||
}
|
||||
|
||||
function handleSectionsReorder(nextItems: QuickAccessSectionId[]) {
|
||||
const nextOrder = normalizeQuickAccessSectionOrder(nextItems);
|
||||
|
||||
if (haveSameKeyOrder(sectionOrder.value, nextOrder, getSectionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
userSettingsStore.set('quickAccessSectionOrder', nextOrder);
|
||||
}
|
||||
|
||||
function handleFavoritesReorder(nextItems: FavoriteItem[]) {
|
||||
userStatsStore.setFavorites(nextItems);
|
||||
}
|
||||
|
||||
function handleTaggedItemsReorder(nextItems: TaggedItem[]) {
|
||||
userStatsStore.setTaggedItems(reorderMatchingItems(
|
||||
taggedItems.value,
|
||||
nextItems,
|
||||
item => item.path,
|
||||
));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,177 +209,105 @@ function openTaggedItem(item: TaggedItem) {
|
||||
|
||||
<ScrollArea class="quick-access-panel__scroll">
|
||||
<div class="quick-access-panel__content">
|
||||
<Collapsible
|
||||
v-model:open="favoritesOpen"
|
||||
class="quick-access-panel__section"
|
||||
<SortableList
|
||||
class="quick-access-panel__sections"
|
||||
:items="sectionOrder"
|
||||
:get-key="getSectionKey"
|
||||
handle-selector=".quick-access-panel__section-drag-handle"
|
||||
@set="handleSectionsReorder"
|
||||
@drag-start="emit('drag-start')"
|
||||
@drag-end="emit('drag-end')"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
class="quick-access-panel__section-trigger"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__section-header"
|
||||
<template #item="{ item: sectionId }">
|
||||
<QuickAccessSection
|
||||
v-if="sectionId === 'favorites'"
|
||||
v-model:open="favoritesOpen"
|
||||
:icon="StarIcon"
|
||||
:title="t('quickAccess.favorites')"
|
||||
:count="favoriteItems.length"
|
||||
:empty-text="t('quickAccess.emptyFavorites')"
|
||||
:is-empty="favoriteItems.length === 0"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
v-if="favoritesOpen"
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
v-else
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<StarIcon
|
||||
:size="14"
|
||||
class="quick-access-panel__section-icon"
|
||||
/>
|
||||
<span class="quick-access-panel__section-title">{{ t('quickAccess.favorites') }}</span>
|
||||
<span
|
||||
v-if="favoriteItems.length > 0"
|
||||
class="quick-access-panel__badge"
|
||||
<SortableList
|
||||
:items="favoriteItems"
|
||||
:get-key="getItemKey"
|
||||
@set="handleFavoritesReorder"
|
||||
@drag-start="emit('drag-start')"
|
||||
@drag-end="handleItemDragEnd"
|
||||
>
|
||||
{{ favoriteItems.length }}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="quick-access-panel__section-content">
|
||||
<div
|
||||
class="quick-access-panel__empty"
|
||||
v-if="favoriteItems.length === 0"
|
||||
>
|
||||
{{ t('quickAccess.emptyFavorites') }}
|
||||
</div>
|
||||
<DirEntryInteractive
|
||||
v-for="item in favoriteItems"
|
||||
:key="item.path"
|
||||
:path="item.path"
|
||||
:is-file="isFavoriteFile(item)"
|
||||
:is-current-directory-context="isCurrentDirectoryItem(item.path, isFavoriteFile(item))"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__item"
|
||||
@click="openFavoriteItem(item)"
|
||||
>
|
||||
<QuickAccessItemIcon
|
||||
:path="item.path"
|
||||
:is-file="isFavoriteFile(item)"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="quick-access-panel__item-name">{{ getItemName(item.path) }}</span>
|
||||
</button>
|
||||
</DirEntryInteractive>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<template #item="{ item }">
|
||||
<QuickAccessEntry
|
||||
v-bind="getFavoriteEntryProps(item)"
|
||||
@open="openFavoriteItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</SortableList>
|
||||
</QuickAccessSection>
|
||||
|
||||
<Collapsible
|
||||
v-model:open="tagsOpen"
|
||||
class="quick-access-panel__section"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
class="quick-access-panel__section-trigger"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__section-header"
|
||||
<QuickAccessSection
|
||||
v-else-if="sectionId === 'tagged'"
|
||||
v-model:open="tagsOpen"
|
||||
:icon="TagIcon"
|
||||
:title="t('quickAccess.tagged')"
|
||||
:count="totalTaggedItemCount"
|
||||
:empty-text="t('quickAccess.emptyTagged')"
|
||||
:is-empty="!hasTaggedSectionContent"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
v-if="tagsOpen"
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
v-else
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<TagIcon
|
||||
:size="14"
|
||||
class="quick-access-panel__section-icon"
|
||||
/>
|
||||
<span class="quick-access-panel__section-title">{{ t('quickAccess.tagged') }}</span>
|
||||
<span
|
||||
v-if="totalTaggedItemCount > 0"
|
||||
class="quick-access-panel__badge"
|
||||
<div
|
||||
v-for="group in tagGroups"
|
||||
:key="group.tag.id"
|
||||
class="quick-access-panel__tag-group"
|
||||
>
|
||||
{{ totalTaggedItemCount }}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="quick-access-panel__section-content">
|
||||
<div
|
||||
class="quick-access-panel__empty"
|
||||
v-if="!hasTaggedSectionContent"
|
||||
>
|
||||
{{ t('quickAccess.emptyTagged') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="group in tagGroups"
|
||||
:key="group.tag.id"
|
||||
class="quick-access-panel__tag-group"
|
||||
>
|
||||
<div class="quick-access-panel__tag-subtitle">
|
||||
<span
|
||||
class="quick-access-panel__tag-dot"
|
||||
:style="{ backgroundColor: group.tag.color }"
|
||||
/>
|
||||
<span class="quick-access-panel__tag-name">{{ group.tag.name }}</span>
|
||||
<span class="quick-access-panel__tag-count">{{ group.items.length }}</span>
|
||||
</div>
|
||||
<DirEntryInteractive
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
:path="item.path"
|
||||
:is-file="item.isFile"
|
||||
:is-current-directory-context="isCurrentDirectoryItem(item.path, item.isFile)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__item"
|
||||
@click="openTaggedItem(item)"
|
||||
>
|
||||
<QuickAccessItemIcon
|
||||
:path="item.path"
|
||||
:is-file="item.isFile"
|
||||
:size="14"
|
||||
<div class="quick-access-panel__tag-subtitle">
|
||||
<span
|
||||
class="quick-access-panel__tag-dot"
|
||||
:style="{ backgroundColor: group.tag.color }"
|
||||
/>
|
||||
<span class="quick-access-panel__item-name">{{ getItemName(item.path) }}</span>
|
||||
</button>
|
||||
</DirEntryInteractive>
|
||||
</div>
|
||||
<div
|
||||
v-if="orphanedTaggedItems.length > 0"
|
||||
class="quick-access-panel__tag-group"
|
||||
>
|
||||
<div class="quick-access-panel__tag-subtitle">
|
||||
<span class="quick-access-panel__tag-dot quick-access-panel__tag-dot--muted" />
|
||||
<span class="quick-access-panel__tag-name">{{ t('quickAccess.unknownTagGroup') }}</span>
|
||||
<span class="quick-access-panel__tag-count">{{ orphanedTaggedItems.length }}</span>
|
||||
</div>
|
||||
<DirEntryInteractive
|
||||
v-for="item in orphanedTaggedItems"
|
||||
:key="item.path"
|
||||
:path="item.path"
|
||||
:is-file="item.isFile"
|
||||
:is-current-directory-context="isCurrentDirectoryItem(item.path, item.isFile)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__item"
|
||||
@click="openTaggedItem(item)"
|
||||
<span class="quick-access-panel__tag-name">{{ group.tag.name }}</span>
|
||||
<span class="quick-access-panel__tag-count">{{ group.items.length }}</span>
|
||||
</div>
|
||||
<SortableList
|
||||
:items="group.items"
|
||||
:get-key="getItemKey"
|
||||
@set="handleTaggedItemsReorder"
|
||||
@drag-start="emit('drag-start')"
|
||||
@drag-end="handleItemDragEnd"
|
||||
>
|
||||
<QuickAccessItemIcon
|
||||
:path="item.path"
|
||||
:is-file="item.isFile"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="quick-access-panel__item-name">{{ getItemName(item.path) }}</span>
|
||||
</button>
|
||||
</DirEntryInteractive>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<template #item="{ item }">
|
||||
<QuickAccessEntry
|
||||
v-bind="getTaggedEntryProps(item)"
|
||||
@open="openTaggedItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</SortableList>
|
||||
</div>
|
||||
<div
|
||||
v-if="orphanedTaggedItems.length > 0"
|
||||
class="quick-access-panel__tag-group"
|
||||
>
|
||||
<div class="quick-access-panel__tag-subtitle">
|
||||
<span class="quick-access-panel__tag-dot quick-access-panel__tag-dot--muted" />
|
||||
<span class="quick-access-panel__tag-name">{{ t('quickAccess.unknownTagGroup') }}</span>
|
||||
<span class="quick-access-panel__tag-count">{{ orphanedTaggedItems.length }}</span>
|
||||
</div>
|
||||
<SortableList
|
||||
:items="orphanedTaggedItems"
|
||||
:get-key="getItemKey"
|
||||
@set="handleTaggedItemsReorder"
|
||||
@drag-start="emit('drag-start')"
|
||||
@drag-end="handleItemDragEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<QuickAccessEntry
|
||||
v-bind="getTaggedEntryProps(item)"
|
||||
@open="openTaggedItem(item)"
|
||||
/>
|
||||
</template>
|
||||
</SortableList>
|
||||
</div>
|
||||
</QuickAccessSection>
|
||||
</template>
|
||||
</SortableList>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -360,123 +352,10 @@ function openTaggedItem(item: TaggedItem) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-access-panel__section {
|
||||
.quick-access-panel__sections :deep(.sortable-list__items) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.quick-access-panel__section-trigger {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
padding-inline-start: 8px;
|
||||
text-align: start;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-header:hover {
|
||||
background-color: hsl(var(--foreground) / 5%);
|
||||
}
|
||||
|
||||
.quick-access-panel__chevron {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.quick-access-panel__section-icon {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--icon));
|
||||
}
|
||||
|
||||
.quick-access-panel__section-title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-access-panel__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: hsl(var(--primary) / 20%);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-content {
|
||||
padding-bottom: 4px;
|
||||
margin-inline-end: 8px;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-content[data-state="closed"] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-access-panel__empty {
|
||||
padding: 8px 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-access-panel__item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
text-align: start;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-access-panel__item:hover {
|
||||
background-color: hsl(var(--foreground) / 5%);
|
||||
}
|
||||
|
||||
:deep(.dir-entry-interactive[data-drag-over]) > .quick-access-panel__item {
|
||||
background-color: var(--drop-target-background);
|
||||
outline: var(--drop-target-outline);
|
||||
outline-offset: var(--drop-target-outline-offset);
|
||||
}
|
||||
|
||||
.quick-access-panel__item-icon {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.quick-access-panel__item-name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-access-panel__tag-group {
|
||||
@@ -491,8 +370,9 @@ function openTaggedItem(item: TaggedItem) {
|
||||
.quick-access-panel__tag-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
gap: 6px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.quick-access-panel__tag-dot {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripVerticalIcon } from '@lucide/vue';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
title: string;
|
||||
count: number;
|
||||
emptyText: string;
|
||||
isEmpty: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsible
|
||||
v-model:open="open"
|
||||
class="quick-access-panel__section"
|
||||
>
|
||||
<div class="quick-access-panel__section-header">
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__section-drag-handle"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
>
|
||||
<GripVerticalIcon :size="14" />
|
||||
</button>
|
||||
<CollapsibleTrigger class="quick-access-panel__section-trigger">
|
||||
<button
|
||||
type="button"
|
||||
class="quick-access-panel__section-toggle"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
v-if="open"
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
v-else
|
||||
:size="14"
|
||||
class="quick-access-panel__chevron"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
:size="14"
|
||||
class="quick-access-panel__section-icon"
|
||||
/>
|
||||
<span class="quick-access-panel__section-title">{{ title }}</span>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="quick-access-panel__badge"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent class="quick-access-panel__section-content">
|
||||
<div
|
||||
v-if="isEmpty"
|
||||
class="quick-access-panel__empty"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
<slot v-else />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-access-panel__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.quick-access-panel__section-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 2px;
|
||||
padding-inline-start: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-header:hover {
|
||||
background-color: hsl(var(--foreground) / 5%);
|
||||
}
|
||||
|
||||
.quick-access-panel__section-drag-handle {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-drag-handle:hover {
|
||||
background-color: hsl(var(--muted) / 40%);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.quick-access-panel__section-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-trigger {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
padding-inline-start: 4px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.quick-access-panel__chevron {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.quick-access-panel__section-icon {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--icon));
|
||||
}
|
||||
|
||||
.quick-access-panel__section-title {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-access-panel__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: hsl(var(--primary) / 20%);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-content {
|
||||
padding-bottom: 4px;
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-content[data-state="open"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.quick-access-panel__section-content[data-state="closed"] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-access-panel__empty {
|
||||
padding: 8px 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -45,14 +45,26 @@ const quickAccessContextMenuOpenCount = ref(0);
|
||||
provide(CONTEXT_MENU_OPEN_COUNT_KEY, quickAccessContextMenuOpenCount);
|
||||
|
||||
const quickAccessHoverOpen = ref(false);
|
||||
const quickAccessReorderOpen = ref(false);
|
||||
const quickAccessTooltipOpen = computed(() =>
|
||||
quickAccessHoverOpen.value || quickAccessContextMenuOpenCount.value > 0,
|
||||
quickAccessHoverOpen.value
|
||||
|| quickAccessContextMenuOpenCount.value > 0
|
||||
|| quickAccessReorderOpen.value,
|
||||
);
|
||||
|
||||
function handleQuickAccessTooltipOpenChange(value: boolean) {
|
||||
quickAccessHoverOpen.value = value;
|
||||
}
|
||||
|
||||
function handleQuickAccessReorderStart() {
|
||||
quickAccessReorderOpen.value = true;
|
||||
}
|
||||
|
||||
function handleQuickAccessReorderEnd() {
|
||||
quickAccessHoverOpen.value = true;
|
||||
quickAccessReorderOpen.value = false;
|
||||
}
|
||||
|
||||
function isDashboardPage(item: { name?: unknown }) {
|
||||
return item.name === 'dashboard';
|
||||
}
|
||||
@@ -175,7 +187,10 @@ function getDriveIcon(drive: {
|
||||
</ContextMenuShortcut>
|
||||
</div>
|
||||
</div>
|
||||
<QuickAccessPanel />
|
||||
<QuickAccessPanel
|
||||
@drag-start="handleQuickAccessReorderStart"
|
||||
@drag-end="handleQuickAccessReorderEnd"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
@@ -383,7 +398,8 @@ function getDriveIcon(drive: {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.nav-sidebar__quick-access-tooltip {
|
||||
.sigma-ui-tooltip-content.nav-sidebar__quick-access-tooltip {
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
border: 1px solid hsl(var(--border) / 50%);
|
||||
margin-top: 0;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_QUICK_ACCESS_SECTION_ORDER,
|
||||
normalizeQuickAccessSectionOrder,
|
||||
} from '../quick-access-section-order';
|
||||
|
||||
describe('normalizeQuickAccessSectionOrder', () => {
|
||||
it('keeps a valid reversed order', () => {
|
||||
expect(normalizeQuickAccessSectionOrder(['tagged', 'favorites'])).toEqual([
|
||||
'tagged',
|
||||
'favorites',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills missing sections and drops unknown values', () => {
|
||||
expect(normalizeQuickAccessSectionOrder(['tagged', 'other', 'tagged'])).toEqual([
|
||||
'tagged',
|
||||
'favorites',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the default order', () => {
|
||||
expect(normalizeQuickAccessSectionOrder(undefined)).toEqual(DEFAULT_QUICK_ACCESS_SECTION_ORDER);
|
||||
expect(normalizeQuickAccessSectionOrder([])).toEqual(DEFAULT_QUICK_ACCESS_SECTION_ORDER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import type { QuickAccessSectionId } from '@/types/user-settings';
|
||||
|
||||
export const DEFAULT_QUICK_ACCESS_SECTION_ORDER: QuickAccessSectionId[] = [
|
||||
'favorites',
|
||||
'tagged',
|
||||
];
|
||||
|
||||
function isQuickAccessSectionId(value: unknown): value is QuickAccessSectionId {
|
||||
return value === 'favorites' || value === 'tagged';
|
||||
}
|
||||
|
||||
export function normalizeQuickAccessSectionOrder(order: unknown): QuickAccessSectionId[] {
|
||||
const seen = new Set<QuickAccessSectionId>();
|
||||
const nextOrder: QuickAccessSectionId[] = [];
|
||||
|
||||
if (Array.isArray(order)) {
|
||||
for (const value of order) {
|
||||
if (!isQuickAccessSectionId(value) || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
nextOrder.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sectionId of DEFAULT_QUICK_ACCESS_SECTION_ORDER) {
|
||||
if (!seen.has(sectionId)) {
|
||||
nextOrder.push(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
return nextOrder;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { useTextDirection } from '@/composables/use-text-direction';
|
||||
import { applyDropResult } from '@/utils/reorder-matching-items';
|
||||
import { useFileBrowserListColumns } from './composables/use-file-browser-list-columns';
|
||||
import { buildCompactListColumnWidths, normalizeListColumnOrder } from './utils/file-browser-list-columns';
|
||||
import { getFileBrowserListColumnLabel } from './utils/file-browser-sort-columns';
|
||||
@@ -45,24 +46,7 @@ function toggleColumnVisibility(column: keyof ListColumnVisibility, checked: boo
|
||||
}
|
||||
|
||||
function getUpdatedColumnOrder(dropResult: DropResult): ListReorderableColumnId[] {
|
||||
const { removedIndex, addedIndex, payload } = dropResult;
|
||||
|
||||
if (removedIndex === null && addedIndex === null) {
|
||||
return reorderableColumnIds.value;
|
||||
}
|
||||
|
||||
const nextOrder = [...reorderableColumnIds.value];
|
||||
let columnToAdd = payload as ListReorderableColumnId;
|
||||
|
||||
if (removedIndex !== null) {
|
||||
columnToAdd = nextOrder.splice(removedIndex, 1)[0];
|
||||
}
|
||||
|
||||
if (addedIndex !== null) {
|
||||
nextOrder.splice(addedIndex, 0, columnToAdd);
|
||||
}
|
||||
|
||||
return normalizeListColumnOrder(nextOrder);
|
||||
return normalizeListColumnOrder(applyDropResult(reorderableColumnIds.value, dropResult));
|
||||
}
|
||||
|
||||
async function handleColumnOrderDrop(dropResult: DropResult) {
|
||||
|
||||
@@ -5,6 +5,7 @@ Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Container, Draggable, type DropResult } from 'vue3-smooth-dnd';
|
||||
import { applyDropResult } from '@/utils/reorder-matching-items';
|
||||
|
||||
interface Props<T> {
|
||||
items: T[];
|
||||
@@ -27,28 +28,7 @@ function getItemGhostParent() {
|
||||
}
|
||||
|
||||
function onDrop(dropResult: DropResult) {
|
||||
emit('set', getUpdatedList(dropResult));
|
||||
}
|
||||
|
||||
function getUpdatedList(dropResult: DropResult) {
|
||||
const { removedIndex, addedIndex, payload } = dropResult;
|
||||
|
||||
if (removedIndex === null && addedIndex === null) {
|
||||
return props.items;
|
||||
}
|
||||
|
||||
const result = [...props.items];
|
||||
let itemToAdd = payload;
|
||||
|
||||
if (removedIndex !== null) {
|
||||
itemToAdd = result.splice(removedIndex, 1)[0];
|
||||
}
|
||||
|
||||
if (addedIndex !== null) {
|
||||
result.splice(addedIndex, 0, itemToAdd);
|
||||
}
|
||||
|
||||
return result;
|
||||
emit('set', applyDropResult(props.items, dropResult));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import {
|
||||
beforeEach, describe, expect, it, vi,
|
||||
} from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import type { StartupStorageFileBootstrap } from '@/stores/storage/utils/startup-storage-bootstrap';
|
||||
import { useUserStatsStore } from '@/stores/storage/user-stats';
|
||||
import { DEFAULT_USER_STATS } from '@/types/user-stats';
|
||||
import type { FavoriteItem, ItemTag, TaggedItem } from '@/types/user-stats';
|
||||
import { reorderMatchingItems } from '@/utils/reorder-matching-items';
|
||||
|
||||
const {
|
||||
lazyStoreSaveMock,
|
||||
lazyStoreSetMock,
|
||||
storedValues,
|
||||
} = vi.hoisted(() => ({
|
||||
lazyStoreSaveMock: vi.fn(),
|
||||
lazyStoreSetMock: vi.fn(),
|
||||
storedValues: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/plugin-store', () => ({
|
||||
LazyStore: class {
|
||||
async save(): Promise<void> {
|
||||
await lazyStoreSaveMock();
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
storedValues.set(key, value);
|
||||
await lazyStoreSetMock(key, value);
|
||||
}
|
||||
|
||||
async entries(): Promise<[string, unknown][]> {
|
||||
return Array.from(storedValues.entries());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/storage/user-paths', () => ({
|
||||
useUserPathsStore: () => ({
|
||||
customPaths: {
|
||||
appUserDataStatsPath: '/tmp/user-data/user-stats.json',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/localization', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function createFavorite(path: string): FavoriteItem {
|
||||
return {
|
||||
path,
|
||||
addedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createTag(id: string, name: string): ItemTag {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color: '#000000',
|
||||
};
|
||||
}
|
||||
|
||||
function createTaggedItem(path: string, tagIds: string[]): TaggedItem {
|
||||
return {
|
||||
path,
|
||||
tagIds,
|
||||
addedAt: 1,
|
||||
isFile: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createStatsBootstrap(data: Record<string, unknown>): StartupStorageFileBootstrap {
|
||||
return {
|
||||
path: '/tmp/user-data/user-stats.json',
|
||||
status: 'ready',
|
||||
data,
|
||||
schemaVersion: 1,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('user stats reorder setters', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
storedValues.clear();
|
||||
lazyStoreSaveMock.mockReset();
|
||||
lazyStoreSetMock.mockReset();
|
||||
});
|
||||
|
||||
it('saves favorites in the UI order', async () => {
|
||||
const favorites = [
|
||||
createFavorite('/a'),
|
||||
createFavorite('/b'),
|
||||
createFavorite('/c'),
|
||||
];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
favorites,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
const nextFavorites = [favorites[2], favorites[0], favorites[1]];
|
||||
await store.setFavorites(nextFavorites);
|
||||
|
||||
expect(store.favorites.map(item => item.path)).toEqual(['/c', '/a', '/b']);
|
||||
expect(lazyStoreSetMock).toHaveBeenCalledWith('favorites', nextFavorites);
|
||||
expect(lazyStoreSaveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not save favorites when membership changes or order is unchanged', async () => {
|
||||
const favorites = [createFavorite('/a'), createFavorite('/b')];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
favorites,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
await store.setFavorites([createFavorite('/a'), createFavorite('/other')]);
|
||||
await store.setFavorites(favorites);
|
||||
|
||||
expect(store.favorites.map(item => item.path)).toEqual(['/a', '/b']);
|
||||
expect(lazyStoreSetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves tagged items in the merged UI order', async () => {
|
||||
const tags = [
|
||||
createTag('tag-work', 'Work'),
|
||||
createTag('tag-personal', 'Personal'),
|
||||
];
|
||||
const taggedItems = [
|
||||
createTaggedItem('/work-1', ['tag-work']),
|
||||
createTaggedItem('/personal-1', ['tag-personal']),
|
||||
createTaggedItem('/work-2', ['tag-work']),
|
||||
];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
tags,
|
||||
taggedItems,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
const nextTaggedItems = [taggedItems[2], taggedItems[1], taggedItems[0]];
|
||||
await store.setTaggedItems(nextTaggedItems);
|
||||
|
||||
expect(store.taggedItems.map(item => item.path)).toEqual([
|
||||
'/work-2',
|
||||
'/personal-1',
|
||||
'/work-1',
|
||||
]);
|
||||
expect(lazyStoreSetMock).toHaveBeenCalledWith('taggedItems', nextTaggedItems);
|
||||
});
|
||||
|
||||
it('persists a tag-group item reorder as a subset merge', async () => {
|
||||
const tags = [
|
||||
createTag('tag-work', 'Work'),
|
||||
createTag('tag-personal', 'Personal'),
|
||||
];
|
||||
const taggedItems = [
|
||||
createTaggedItem('/work-1', ['tag-work']),
|
||||
createTaggedItem('/personal-1', ['tag-personal']),
|
||||
createTaggedItem('/work-2', ['tag-work']),
|
||||
];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
tags,
|
||||
taggedItems,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
const workItems = [taggedItems[2], taggedItems[0]];
|
||||
const nextTaggedItems = reorderMatchingItems(
|
||||
store.taggedItems,
|
||||
workItems,
|
||||
item => item.path,
|
||||
);
|
||||
await store.setTaggedItems(nextTaggedItems);
|
||||
|
||||
expect(store.taggedItems.map(item => item.path)).toEqual([
|
||||
'/work-2',
|
||||
'/personal-1',
|
||||
'/work-1',
|
||||
]);
|
||||
expect(lazyStoreSetMock).toHaveBeenCalledWith('taggedItems', nextTaggedItems);
|
||||
});
|
||||
});
|
||||
@@ -206,6 +206,7 @@ export const useUserSettingsStore = defineStore('userSettings', () => {
|
||||
focusWindowOnDriveConnected: true,
|
||||
preventDropdownCloseFocusReturn: false,
|
||||
quickAccessOnHover: true,
|
||||
quickAccessSectionOrder: ['favorites', 'tagged'],
|
||||
tooltipDelayMs: 0,
|
||||
launchAtStartup: false,
|
||||
launchAtStartupHidden: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './utils/startup-storage-bootstrap';
|
||||
import { i18n } from '@/localization';
|
||||
import { reconcileMissingTagDefinitions as mergeTagDefinitionsFromTaggedItems } from '@/utils/reconcile-user-stats-tags';
|
||||
import { haveSameKeyOrder, haveSameKeys } from '@/utils/reorder-matching-items';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-path-constants';
|
||||
|
||||
const HISTORY_MAX_ITEMS = 100;
|
||||
@@ -299,6 +300,46 @@ export const useUserStatsStore = defineStore('userStats', () => {
|
||||
await saveStats();
|
||||
}
|
||||
|
||||
async function replaceListIfMembershipMatches<T>(
|
||||
currentItems: T[],
|
||||
nextItems: T[],
|
||||
getKey: (item: T) => string,
|
||||
assign: (items: T[]) => void,
|
||||
) {
|
||||
if (!haveSameKeys(currentItems, nextItems, getKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (haveSameKeyOrder(currentItems, nextItems, getKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
assign(nextItems);
|
||||
await saveStats();
|
||||
}
|
||||
|
||||
async function setFavorites(nextFavorites: FavoriteItem[]) {
|
||||
await replaceListIfMembershipMatches(
|
||||
userStats.value.favorites,
|
||||
nextFavorites,
|
||||
item => item.path,
|
||||
(items) => {
|
||||
userStats.value.favorites = items;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function setTaggedItems(nextTaggedItems: TaggedItem[]) {
|
||||
await replaceListIfMembershipMatches(
|
||||
userStats.value.taggedItems,
|
||||
nextTaggedItems,
|
||||
item => item.path,
|
||||
(items) => {
|
||||
userStats.value.taggedItems = items;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function updateTagColor(tagId: string, color: string) {
|
||||
const tag = userStats.value.tags.find(tagItem => tagItem.id === tagId);
|
||||
|
||||
@@ -582,6 +623,8 @@ export const useUserStatsStore = defineStore('userStats', () => {
|
||||
createTag,
|
||||
deleteTag,
|
||||
renameTag,
|
||||
setFavorites,
|
||||
setTaggedItems,
|
||||
updateTagColor,
|
||||
recordItemOpen,
|
||||
clearHistory,
|
||||
|
||||
@@ -175,6 +175,8 @@ export type ClipboardSettings = {
|
||||
showToolbarForExternalPaths: boolean;
|
||||
};
|
||||
|
||||
export type QuickAccessSectionId = 'favorites' | 'tagged';
|
||||
|
||||
export type UserSettings = {
|
||||
language: LocalizationLanguage;
|
||||
theme: Theme;
|
||||
@@ -202,6 +204,7 @@ export type UserSettings = {
|
||||
focusWindowOnDriveConnected: boolean;
|
||||
preventDropdownCloseFocusReturn: boolean;
|
||||
quickAccessOnHover: boolean;
|
||||
quickAccessSectionOrder: QuickAccessSectionId[];
|
||||
tooltipDelayMs: number;
|
||||
launchAtStartup: boolean;
|
||||
launchAtStartupHidden: boolean;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyDropResult,
|
||||
haveSameKeyOrder,
|
||||
haveSameKeys,
|
||||
reorderMatchingItems,
|
||||
} from '@/utils/reorder-matching-items';
|
||||
|
||||
describe('applyDropResult', () => {
|
||||
it('moves an item from removedIndex to addedIndex', () => {
|
||||
const items = ['a', 'b', 'c', 'd'];
|
||||
|
||||
expect(applyDropResult(items, {
|
||||
removedIndex: 0,
|
||||
addedIndex: 2,
|
||||
payload: 'a',
|
||||
})).toEqual(['b', 'c', 'a', 'd']);
|
||||
});
|
||||
|
||||
it('returns the same array when both indexes are null', () => {
|
||||
const items = ['a', 'b'];
|
||||
|
||||
expect(applyDropResult(items, {
|
||||
removedIndex: null,
|
||||
addedIndex: null,
|
||||
payload: 'a',
|
||||
})).toBe(items);
|
||||
});
|
||||
|
||||
it('uses the spliced item when payload differs', () => {
|
||||
expect(applyDropResult(['a', 'b', 'c'], {
|
||||
removedIndex: 2,
|
||||
addedIndex: 0,
|
||||
payload: 'ignored',
|
||||
})).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('haveSameKeys', () => {
|
||||
it('returns true when both lists have the same keys', () => {
|
||||
expect(haveSameKeys(
|
||||
[{ id: 'b' }, { id: 'a' }],
|
||||
[{ id: 'a' }, { id: 'b' }],
|
||||
item => item.id,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when membership differs', () => {
|
||||
expect(haveSameKeys(
|
||||
[{ id: 'a' }, { id: 'b' }],
|
||||
[{ id: 'a' }, { id: 'c' }],
|
||||
item => item.id,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('haveSameKeyOrder', () => {
|
||||
it('returns true only when keys are in the same order', () => {
|
||||
function getKey(item: { id: string }) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
expect(haveSameKeyOrder(
|
||||
[{ id: 'a' }, { id: 'b' }],
|
||||
[{ id: 'a' }, { id: 'b' }],
|
||||
getKey,
|
||||
)).toBe(true);
|
||||
|
||||
expect(haveSameKeyOrder(
|
||||
[{ id: 'a' }, { id: 'b' }],
|
||||
[{ id: 'b' }, { id: 'a' }],
|
||||
getKey,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderMatchingItems', () => {
|
||||
it('reorders only matching items and leaves others in place', () => {
|
||||
const source = [
|
||||
{ id: 'work-1' },
|
||||
{ id: 'other' },
|
||||
{ id: 'work-2' },
|
||||
{ id: 'work-3' },
|
||||
];
|
||||
const nextSubset = [
|
||||
{ id: 'work-3' },
|
||||
{ id: 'work-1' },
|
||||
{ id: 'work-2' },
|
||||
];
|
||||
|
||||
expect(reorderMatchingItems(source, nextSubset, item => item.id)).toEqual([
|
||||
{ id: 'work-3' },
|
||||
{ id: 'other' },
|
||||
{ id: 'work-1' },
|
||||
{ id: 'work-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps empty-tag slots when reordering visible tags', () => {
|
||||
const tags = [
|
||||
{ id: 'important' },
|
||||
{ id: 'archive' },
|
||||
{ id: 'work' },
|
||||
{ id: 'personal' },
|
||||
];
|
||||
const nextVisible = [
|
||||
{ id: 'personal' },
|
||||
{ id: 'important' },
|
||||
{ id: 'work' },
|
||||
];
|
||||
|
||||
expect(reorderMatchingItems(tags, nextVisible, tag => tag.id)).toEqual([
|
||||
{ id: 'personal' },
|
||||
{ id: 'archive' },
|
||||
{ id: 'important' },
|
||||
{ id: 'work' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export interface ListDropResult {
|
||||
removedIndex: number | null;
|
||||
addedIndex: number | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export function applyDropResult<T>(items: T[], dropResult: ListDropResult): T[] {
|
||||
const { removedIndex, addedIndex, payload } = dropResult;
|
||||
|
||||
if (removedIndex === null && addedIndex === null) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const result = [...items];
|
||||
let itemToAdd = payload as T;
|
||||
|
||||
if (removedIndex !== null) {
|
||||
const [removedItem] = result.splice(removedIndex, 1);
|
||||
itemToAdd = removedItem;
|
||||
}
|
||||
|
||||
if (addedIndex !== null) {
|
||||
result.splice(addedIndex, 0, itemToAdd);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function haveSameKeys<T>(
|
||||
currentItems: T[],
|
||||
nextItems: T[],
|
||||
getKey: (item: T) => string,
|
||||
): boolean {
|
||||
if (currentItems.length !== nextItems.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentKeys = currentItems.map(getKey).slice().sort();
|
||||
const nextKeys = nextItems.map(getKey).slice().sort();
|
||||
|
||||
return currentKeys.every((key, index) => key === nextKeys[index]);
|
||||
}
|
||||
|
||||
export function haveSameKeyOrder<T>(
|
||||
currentItems: T[],
|
||||
nextItems: T[],
|
||||
getKey: (item: T) => string,
|
||||
): boolean {
|
||||
return currentItems.length === nextItems.length
|
||||
&& currentItems.every((item, index) => getKey(item) === getKey(nextItems[index]));
|
||||
}
|
||||
|
||||
export function reorderMatchingItems<T>(
|
||||
source: T[],
|
||||
nextSubset: T[],
|
||||
getKey: (item: T) => string,
|
||||
): T[] {
|
||||
const subsetKeys = new Set(nextSubset.map(getKey));
|
||||
const queue = [...nextSubset];
|
||||
|
||||
return source.map((item) => {
|
||||
if (!subsetKeys.has(getKey(item))) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return queue.shift() ?? item;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user