New feature: allow to reorder items in quick panel

This commit is contained in:
aleksey-hoffman committed 2026-08-27 00:02:28 +03:00
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);
});
});
+5
View File
@@ -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>
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "文件已在网络上共享。当前共享将被停止并替换为新的共享。",
"replaceShareDialogConfirm": "替换"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Es werden bereits Dateien im Netzwerk freigegeben. Die aktuelle Freigabe wird gestoppt und durch eine neue ersetzt.",
"replaceShareDialogConfirm": "Ersetzen"
}
}
}
+1 -1
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "فایل‌ها از قبل از طریق شبکه به اشتراک گذاشته شده‌اند. اشتراک‌گذاری فعلی متوقف و با مورد جدید جایگزین خواهد شد.",
"replaceShareDialogConfirm": "جایگزینی"
}
}
}
+1 -1
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "קבצים כבר משותפים ברשת. השיתוף הנוכחי ייעצר ויוחלף בחדש.",
"replaceShareDialogConfirm": "החלף"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "फ़ाइलें पहले से ही नेटवर्क पर साझा की जा रही हैं. वर्तमान शेयर बंद कर दिया जाएगा और उसके स्थान पर नया शेयर ले लिया जाएगा।",
"replaceShareDialogConfirm": "प्रतिस्थापित करें"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Ֆայլերն արդեն կիսվում են ցանցում: Ընթացիկ կիսումը կդադարեցվի և կփոխարինվի նորով:",
"replaceShareDialogConfirm": "Փոխարինել"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Ci sono già file condivisi in rete. La condivisione attuale verrà interrotta e sostituita con quella nuova.",
"replaceShareDialogConfirm": "Sostituisci"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "すでにネットワーク上でファイルが共有されています。現在の共有を停止し、新しいものに置き換えます。",
"replaceShareDialogConfirm": "置き換え"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Já existem arquivos sendo compartilhados na rede. O compartilhamento atual será interrompido e substituído pelo novo.",
"replaceShareDialogConfirm": "Substituir"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Файлы уже доступны по сети. Текущий доступ будет остановлен и заменён новым.",
"replaceShareDialogConfirm": "Заменить"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Datoteke se že delijo po omrežju. Trenutno deljenje bo ustavljeno in zamenjano z novim.",
"replaceShareDialogConfirm": "Zamenjaj"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "Ağ üzerinden zaten dosyalar paylaşılıyor. Mevcut paylaşım durdurulup yenisiyle değiştirilecek.",
"replaceShareDialogConfirm": "Değiştir"
}
}
}
+1 -1
View File
@@ -1260,4 +1260,4 @@
"replaceShareDialogDescription": "فائلیں پہلے سے نیٹ ورک پر ہیں۔ موجودہ شیئر بند ہو کر نئے سے بدل جائے گا۔",
"replaceShareDialogConfirm": "بدل دیں"
}
}
}
+1 -1
View File
@@ -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>
+19 -3
View File
@@ -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);
});
});
+1
View File
@@ -206,6 +206,7 @@ export const useUserSettingsStore = defineStore('userSettings', () => {
focusWindowOnDriveConnected: true,
preventDropdownCloseFocusReturn: false,
quickAccessOnHover: true,
quickAccessSectionOrder: ['favorites', 'tagged'],
tooltipDelayMs: 0,
launchAtStartup: false,
launchAtStartupHidden: false,
+43
View File
@@ -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,
+3
View File
@@ -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' },
]);
});
});
+72
View File
@@ -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;
});
}