Skip to content

Commit 9dc2e96

Browse files
committed
Favorites: fix drag-down cue offset, keyboard-reorder highlight jump, and move dev Debug to Cmd+Shift+D
- Drag-down drop-line cue was one gap too high: it reused the post-removal move-target as a row index. Now driven by the raw insertion slot (new pure `pointerInsertionSlot`), with a bottom-edge cue for dropping past the last favorite. Cue and drop now agree. - Keyboard reorder (Alt+Up/Down) moved the highlight to the current volume (Macintosh HD) instead of following the moved favorite: the open-effect re-ran on the post-reorder `volumes-changed` refresh and reset it. The init now reads the list `untrack`ed (fires only on open), and the reorder sets the highlight to the moved favorite's new index directly (favorites lead `allVolumes` in order) rather than racing an async findIndex. - Dev-only Debug window shortcut moved from Cmd+D to Cmd+Shift+D, freeing Cmd+D.
1 parent 335331e commit 9dc2e96

4 files changed

Lines changed: 68 additions & 26 deletions

File tree

apps/desktop/src/lib/file-explorer/navigation/VolumeBreadcrumb.svelte

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { onMount, onDestroy, tick } from 'svelte'
2+
import { onMount, onDestroy, tick, untrack } from 'svelte'
33
import {
44
ejectVolume,
55
getIpcErrorMessage,
@@ -15,7 +15,7 @@
1515
reorderFavorites,
1616
stripFavoritePrefix,
1717
} from '$lib/tauri-commands'
18-
import { moveItem, clampedReorderTarget, pointerReorderTarget } from './favorites-reorder'
18+
import { moveItem, clampedReorderTarget, pointerReorderTarget, pointerInsertionSlot } from './favorites-reorder'
1919
import { ask } from '@tauri-apps/plugin-dialog'
2020
import { triggerNetworkDiscovery } from '../network/lazy-trigger'
2121
import { addToast, dismissToast } from '$lib/ui/toast'
@@ -166,12 +166,17 @@
166166
// Flat list of all volumes for keyboard navigation
167167
const allVolumes = $derived(groupedVolumes.flatMap((g) => g.items))
168168
169-
// When dropdown opens, initialize highlight to current volume and fit to viewport
169+
// When dropdown opens, initialize highlight to current volume and fit to viewport.
170170
$effect(() => {
171171
if (isOpen) {
172-
const currentIdx = allVolumes.findIndex((v) => shouldShowCheckmark(v, containingVolumeId))
173-
highlightedIndex = currentIdx >= 0 ? currentIdx : 0
174-
void fitDropdownToViewport()
172+
// Init ONCE on open. Read the volume list untracked: otherwise a later `volumes-changed`
173+
// refresh (for example right after a favorite reorder) re-runs this effect and resets the
174+
// highlight to the current volume, stealing it from the just-moved favorite.
175+
untrack(() => {
176+
const currentIdx = allVolumes.findIndex((v) => shouldShowCheckmark(v, containingVolumeId))
177+
highlightedIndex = currentIdx >= 0 ? currentIdx : 0
178+
void fitDropdownToViewport()
179+
})
175180
} else {
176181
highlightedIndex = -1
177182
keyboardMode.reset()
@@ -292,16 +297,13 @@
292297
const delta = e.key === 'ArrowUp' ? -1 : 1
293298
void moveHighlightedFavorite(highlighted, delta).then((newFavIndex) => {
294299
if (newFavIndex === null) return
295-
// Follow the moved favorite so repeated Alt+Down keeps moving the same item.
296-
void tick().then(() => {
297-
const movedIdx = allVolumes.findIndex(
298-
(v) => v.category === 'favorite' && v.id === highlighted.id,
299-
)
300-
if (movedIdx >= 0) {
301-
highlightedIndex = movedIdx
302-
enterKeyboardMode()
303-
}
304-
})
300+
// Favorites occupy the first slots of `allVolumes` in order, so the moved
301+
// favorite's new favorites index IS its new list index. Set it directly (no
302+
// findIndex against a list that may not have refreshed from `volumes-changed`
303+
// yet), so repeated Alt+Down keeps moving the same item instead of jumping to the
304+
// first volume. The open-effect's `untrack` keeps the refresh from resetting it.
305+
highlightedIndex = newFavIndex
306+
enterKeyboardMode()
305307
})
306308
return true
307309
}
@@ -636,8 +638,12 @@
636638
}
637639
const from = favorites.findIndex((f) => f.id === grabbed.id)
638640
if (from < 0) return
639-
const target = pointerReorderTarget(favoriteRowMidpoints(), e.clientY, from)
640-
dragOverIndex = target ?? from
641+
// Drive the cue off the RAW insertion slot (the visual gap), not the move-target: dropping at
642+
// slot `from` or `from + 1` leaves the item in place, so hide the cue then (matches when the
643+
// drop's `pointerReorderTarget` returns null). Using the move-target here put the line one row
644+
// too high on downward drags.
645+
const slot = pointerInsertionSlot(favoriteRowMidpoints(), e.clientY)
646+
dragOverIndex = slot === from || slot === from + 1 ? null : slot
641647
}
642648
643649
function handleFavoriteMouseUp(e: MouseEvent) {
@@ -855,6 +861,10 @@
855861
class:favorite-item={isFavorite}
856862
class:is-dragging={isFavorite && draggingFavoriteId === volume.id}
857863
class:is-drag-over={isFavorite && dragOverIndex === favIndex && draggingFavoriteId !== volume.id}
864+
class:is-drag-over-end={isFavorite &&
865+
draggingFavoriteId !== volume.id &&
866+
dragOverIndex === favorites.length &&
867+
favIndex === favorites.length - 1}
858868
class:is-under-cursor={shouldShowCheckmark(volume, containingVolumeId)}
859869
class:is-focused-and-under-cursor={allVolumes.indexOf(volume) === highlightedIndex && !submenu.volumeId}
860870
class:is-restricted={isRestricted(volume.path)}
@@ -1276,6 +1286,12 @@
12761286
box-shadow: inset 0 2px 0 0 var(--color-accent);
12771287
}
12781288
1289+
/* Drop at the very end of the list: bottom border on the last favorite. */
1290+
/*noinspection CssUnusedSymbol*/
1291+
.favorite-item.is-drag-over-end {
1292+
box-shadow: inset 0 -2px 0 0 var(--color-accent);
1293+
}
1294+
12791295
.favorite-rename-input {
12801296
flex: 1;
12811297
min-width: 0;

apps/desktop/src/lib/file-explorer/navigation/favorites-reorder.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { moveItem, clampedReorderTarget, pointerReorderTarget } from './favorites-reorder'
2+
import { moveItem, clampedReorderTarget, pointerReorderTarget, pointerInsertionSlot } from './favorites-reorder'
33

44
describe('moveItem', () => {
55
it('moves an item later in the list, shifting the rest', () => {
@@ -75,3 +75,18 @@ describe('pointerReorderTarget', () => {
7575
expect(pointerReorderTarget(midpoints, 50, 9)).toBeNull()
7676
})
7777
})
78+
79+
describe('pointerInsertionSlot', () => {
80+
// Four rows, 20px tall, stacked from y=0: midpoints at 10, 30, 50, 70.
81+
const midpoints = [10, 30, 50, 70]
82+
83+
it('returns the gap index for the drop-line cue (NOT adjusted for removal)', () => {
84+
// Above every midpoint → insert before row 0.
85+
expect(pointerInsertionSlot(midpoints, 5)).toBe(0)
86+
// Between rows 2 and 3 (below midpoints 10/30/50, above 70) → slot 3, the gap above row 3.
87+
// This is the case that was one row too high when the cue reused the move-target.
88+
expect(pointerInsertionSlot(midpoints, 60)).toBe(3)
89+
// Below every midpoint → slot 4 (past the last row, the bottom-edge cue).
90+
expect(pointerInsertionSlot(midpoints, 999)).toBe(4)
91+
})
92+
})

apps/desktop/src/lib/file-explorer/navigation/favorites-reorder.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,25 @@ export function clampedReorderTarget(from: number, delta: number, length: number
4242
*/
4343
export function pointerReorderTarget(midpoints: readonly number[], pointerY: number, from: number): number | null {
4444
if (from < 0 || from >= midpoints.length) return null
45-
// Number of rows whose midpoint is above the pointer = the insertion slot.
46-
let slot = 0
47-
for (const mid of midpoints) {
48-
if (pointerY > mid) slot++
49-
}
45+
const slot = pointerInsertionSlot(midpoints, pointerY)
5046
// `slot` is an insertion index into the full list (0..length). Clamp to a valid move target and
5147
// account for the grabbed item being removed first: a slot past `from` shifts down by one.
5248
const target = slot > from ? slot - 1 : slot
5349
const clamped = Math.max(0, Math.min(midpoints.length - 1, target))
5450
return clamped === from ? null : clamped
5551
}
52+
53+
/**
54+
* The raw insertion slot for the drop-line CUE: how many rows the pointer sits below, i.e. the gap
55+
* index in `0..length` where the grabbed item would be inserted. Unlike `pointerReorderTarget`, this
56+
* is NOT adjusted for the grabbed item being removed first, so it maps directly to a visual gap (slot
57+
* `k` = the line above row `k`; slot `length` = below the last row). Keeping the cue on the raw slot
58+
* is why a downward drag highlights the correct gap instead of one row too high.
59+
*/
60+
export function pointerInsertionSlot(midpoints: readonly number[], pointerY: number): number {
61+
let slot = 0
62+
for (const mid of midpoints) {
63+
if (pointerY > mid) slot++
64+
}
65+
return slot
66+
}

apps/desktop/src/routes/(main)/+page.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@
127127
}
128128
}
129129
130-
/** Check if key event matches ⌘D (debug window, dev only) */
130+
/** Check if key event matches ⌘D (debug window, dev only) */
131131
function isDebugWindowShortcut(e: KeyboardEvent): boolean {
132-
return import.meta.env.DEV && e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd'
132+
return import.meta.env.DEV && e.metaKey && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd'
133133
}
134134
135135
/** Check if key event should be suppressed (Cmd+A, Cmd+Opt+I in prod) */

0 commit comments

Comments
 (0)