diff --git a/src/components/sortable-list/__tests__/sortable-geometry.test.ts b/src/components/sortable-list/__tests__/sortable-geometry.test.ts new file mode 100644 index 00000000..5f42fed8 --- /dev/null +++ b/src/components/sortable-list/__tests__/sortable-geometry.test.ts @@ -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); + }); +}); diff --git a/src/components/sortable-list/index.ts b/src/components/sortable-list/index.ts new file mode 100644 index 00000000..7bd286b7 --- /dev/null +++ b/src/components/sortable-list/index.ts @@ -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'; diff --git a/src/components/sortable-list/sortable-geometry.ts b/src/components/sortable-list/sortable-geometry.ts new file mode 100644 index 00000000..beac859f --- /dev/null +++ b/src/components/sortable-list/sortable-geometry.ts @@ -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; +} diff --git a/src/components/sortable-list/sortable-list.vue b/src/components/sortable-list/sortable-list.vue new file mode 100644 index 00000000..291fdad5 --- /dev/null +++ b/src/components/sortable-list/sortable-list.vue @@ -0,0 +1,317 @@ + + + + + + + + + diff --git a/src/localization/messages/ch.json b/src/localization/messages/ch.json index 6b41c6de..26ce8d16 100644 --- a/src/localization/messages/ch.json +++ b/src/localization/messages/ch.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "文件已在网络上共享。当前共享将被停止并替换为新的共享。", "replaceShareDialogConfirm": "替换" } -} \ No newline at end of file +} diff --git a/src/localization/messages/de.json b/src/localization/messages/de.json index b18ebddf..926c6d9d 100644 --- a/src/localization/messages/de.json +++ b/src/localization/messages/de.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Es werden bereits Dateien im Netzwerk freigegeben. Die aktuelle Freigabe wird gestoppt und durch eine neue ersetzt.", "replaceShareDialogConfirm": "Ersetzen" } -} \ No newline at end of file +} diff --git a/src/localization/messages/es.json b/src/localization/messages/es.json index 074f390b..d2652887 100644 --- a/src/localization/messages/es.json +++ b/src/localization/messages/es.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/localization/messages/fa.json b/src/localization/messages/fa.json index cfe9dd04..870fb60d 100644 --- a/src/localization/messages/fa.json +++ b/src/localization/messages/fa.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "فایل‌ها از قبل از طریق شبکه به اشتراک گذاشته شده‌اند. اشتراک‌گذاری فعلی متوقف و با مورد جدید جایگزین خواهد شد.", "replaceShareDialogConfirm": "جایگزینی" } -} \ No newline at end of file +} diff --git a/src/localization/messages/fr.json b/src/localization/messages/fr.json index a32f34f2..1d8fe720 100644 --- a/src/localization/messages/fr.json +++ b/src/localization/messages/fr.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/localization/messages/he.json b/src/localization/messages/he.json index 51f7848c..c45000de 100644 --- a/src/localization/messages/he.json +++ b/src/localization/messages/he.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "קבצים כבר משותפים ברשת. השיתוף הנוכחי ייעצר ויוחלף בחדש.", "replaceShareDialogConfirm": "החלף" } -} \ No newline at end of file +} diff --git a/src/localization/messages/hi.json b/src/localization/messages/hi.json index 3afb59a6..af8fe8de 100644 --- a/src/localization/messages/hi.json +++ b/src/localization/messages/hi.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "फ़ाइलें पहले से ही नेटवर्क पर साझा की जा रही हैं. वर्तमान शेयर बंद कर दिया जाएगा और उसके स्थान पर नया शेयर ले लिया जाएगा।", "replaceShareDialogConfirm": "प्रतिस्थापित करें" } -} \ No newline at end of file +} diff --git a/src/localization/messages/hy.json b/src/localization/messages/hy.json index e6de2224..ad71a62a 100644 --- a/src/localization/messages/hy.json +++ b/src/localization/messages/hy.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Ֆայլերն արդեն կիսվում են ցանցում: Ընթացիկ կիսումը կդադարեցվի և կփոխարինվի նորով:", "replaceShareDialogConfirm": "Փոխարինել" } -} \ No newline at end of file +} diff --git a/src/localization/messages/it.json b/src/localization/messages/it.json index 25ae413f..ddb9d443 100644 --- a/src/localization/messages/it.json +++ b/src/localization/messages/it.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Ci sono già file condivisi in rete. La condivisione attuale verrà interrotta e sostituita con quella nuova.", "replaceShareDialogConfirm": "Sostituisci" } -} \ No newline at end of file +} diff --git a/src/localization/messages/ja.json b/src/localization/messages/ja.json index b2ea43f5..643f403c 100644 --- a/src/localization/messages/ja.json +++ b/src/localization/messages/ja.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "すでにネットワーク上でファイルが共有されています。現在の共有を停止し、新しいものに置き換えます。", "replaceShareDialogConfirm": "置き換え" } -} \ No newline at end of file +} diff --git a/src/localization/messages/pt.json b/src/localization/messages/pt.json index 01f2dad4..4cffaea1 100644 --- a/src/localization/messages/pt.json +++ b/src/localization/messages/pt.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Já existem arquivos sendo compartilhados na rede. O compartilhamento atual será interrompido e substituído pelo novo.", "replaceShareDialogConfirm": "Substituir" } -} \ No newline at end of file +} diff --git a/src/localization/messages/ru.json b/src/localization/messages/ru.json index 9ae45315..6bbe788c 100644 --- a/src/localization/messages/ru.json +++ b/src/localization/messages/ru.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Файлы уже доступны по сети. Текущий доступ будет остановлен и заменён новым.", "replaceShareDialogConfirm": "Заменить" } -} \ No newline at end of file +} diff --git a/src/localization/messages/sl.json b/src/localization/messages/sl.json index 449a15d3..7a8b3d9d 100644 --- a/src/localization/messages/sl.json +++ b/src/localization/messages/sl.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Datoteke se že delijo po omrežju. Trenutno deljenje bo ustavljeno in zamenjano z novim.", "replaceShareDialogConfirm": "Zamenjaj" } -} \ No newline at end of file +} diff --git a/src/localization/messages/tr.json b/src/localization/messages/tr.json index 9949bbce..3c611795 100644 --- a/src/localization/messages/tr.json +++ b/src/localization/messages/tr.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "Ağ üzerinden zaten dosyalar paylaşılıyor. Mevcut paylaşım durdurulup yenisiyle değiştirilecek.", "replaceShareDialogConfirm": "Değiştir" } -} \ No newline at end of file +} diff --git a/src/localization/messages/ur.json b/src/localization/messages/ur.json index 9361d8ba..90d17714 100644 --- a/src/localization/messages/ur.json +++ b/src/localization/messages/ur.json @@ -1260,4 +1260,4 @@ "replaceShareDialogDescription": "فائلیں پہلے سے نیٹ ورک پر ہیں۔ موجودہ شیئر بند ہو کر نئے سے بدل جائے گا۔", "replaceShareDialogConfirm": "بدل دیں" } -} \ No newline at end of file +} diff --git a/src/localization/messages/vi.json b/src/localization/messages/vi.json index 1a80c58e..1f68f648 100644 --- a/src/localization/messages/vi.json +++ b/src/localization/messages/vi.json @@ -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ế" } -} \ No newline at end of file +} diff --git a/src/modules/nav-sidebar/components/quick-access-entry.vue b/src/modules/nav-sidebar/components/quick-access-entry.vue new file mode 100644 index 00000000..882036b4 --- /dev/null +++ b/src/modules/nav-sidebar/components/quick-access-entry.vue @@ -0,0 +1,94 @@ + + + + + + + + + diff --git a/src/modules/nav-sidebar/components/quick-access-panel.vue b/src/modules/nav-sidebar/components/quick-access-panel.vue index 4b8e0405..83715a8d 100644 --- a/src/modules/nav-sidebar/components/quick-access-panel.vue +++ b/src/modules/nav-sidebar/components/quick-access-panel.vue @@ -14,33 +14,32 @@ const tagsOpenState = ref(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(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, + )); +} + @@ -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 { diff --git a/src/modules/nav-sidebar/components/quick-access-section.vue b/src/modules/nav-sidebar/components/quick-access-section.vue new file mode 100644 index 00000000..2fd4c27a --- /dev/null +++ b/src/modules/nav-sidebar/components/quick-access-section.vue @@ -0,0 +1,200 @@ + + + + + + + diff --git a/src/modules/nav-sidebar/nav-sidebar.vue b/src/modules/nav-sidebar/nav-sidebar.vue index 05605f42..5007aeae 100644 --- a/src/modules/nav-sidebar/nav-sidebar.vue +++ b/src/modules/nav-sidebar/nav-sidebar.vue @@ -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: { - +