Skip to content

Commit 453ec02

Browse files
committed
Selection: Adjust indices on structural diffs
- Add `index` field to `DiffChange` (old-listing index for removes, new-listing index for adds) - Pure `adjustSelectionIndices()` maps old selection to new positions via remove-compact + add-merge-step - Wire into `directory-diff` handler as fallback when no file operation is active - Fixes selection drift after mkdir, external file creation/deletion, cloud sync, etc. - Simplify `compute_diff`: `new_map` to `HashSet`, `old_map` drops unused index, remove duplicate inline tests
1 parent 4ff6e05 commit 453ec02

6 files changed

Lines changed: 242 additions & 82 deletions

File tree

apps/desktop/src-tauri/src/file_system/watcher.rs

Lines changed: 15 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use notify_debouncer_full::{
88
notify::{RecommendedWatcher, RecursiveMode},
99
};
1010
use serde::{Deserialize, Serialize};
11-
use std::collections::HashMap;
11+
use std::collections::{HashMap, HashSet};
1212
use std::path::Path;
1313
use std::sync::{LazyLock, RwLock};
1414
use std::time::Duration;
@@ -45,6 +45,8 @@ pub struct DiffChange {
4545
#[serde(rename = "type")]
4646
pub change_type: String,
4747
pub entry: FileEntry,
48+
/// Position in the sorted listing: old listing for `"remove"`, new listing for `"add"`/`"modify"`.
49+
pub index: usize,
4850
}
4951

5052
/// Diff event sent to frontend
@@ -244,37 +246,39 @@ pub fn compute_diff(old: &[FileEntry], new: &[FileEntry]) -> Vec<DiffChange> {
244246
let mut changes = Vec::new();
245247

246248
// Create lookup maps by path
247-
let old_map: HashMap<&str, &FileEntry> = old.iter().map(|e| (e.path.as_str(), e)).collect();
248-
let new_map: HashMap<&str, &FileEntry> = new.iter().map(|e| (e.path.as_str(), e)).collect();
249+
let old_map: HashMap<&str, &FileEntry> =
250+
old.iter().map(|e| (e.path.as_str(), e)).collect();
251+
let new_map: HashSet<&str> = new.iter().map(|e| e.path.as_str()).collect();
249252

250-
// Find additions and modifications
251-
for new_entry in new {
253+
// Find additions and modifications (index refers to position in new listing)
254+
for (new_index, new_entry) in new.iter().enumerate() {
252255
match old_map.get(new_entry.path.as_str()) {
253256
None => {
254-
// New entry - addition
255257
changes.push(DiffChange {
256258
change_type: "add".to_string(),
257259
entry: new_entry.clone(),
260+
index: new_index,
258261
});
259262
}
260263
Some(old_entry) => {
261-
// Exists in both - check if modified
262264
if is_entry_modified(old_entry, new_entry) {
263265
changes.push(DiffChange {
264266
change_type: "modify".to_string(),
265267
entry: new_entry.clone(),
268+
index: new_index,
266269
});
267270
}
268271
}
269272
}
270273
}
271274

272-
// Find removals
273-
for old_entry in old {
274-
if !new_map.contains_key(old_entry.path.as_str()) {
275+
// Find removals (index refers to position in old listing)
276+
for (old_index, old_entry) in old.iter().enumerate() {
277+
if !new_map.contains(old_entry.path.as_str()) {
275278
changes.push(DiffChange {
276279
change_type: "remove".to_string(),
277280
entry: old_entry.clone(),
281+
index: old_index,
278282
});
279283
}
280284
}
@@ -289,75 +293,4 @@ fn is_entry_modified(old: &FileEntry, new: &FileEntry) -> bool {
289293
|| old.permissions != new.permissions
290294
|| old.is_directory != new.is_directory
291295
|| old.is_symlink != new.is_symlink
292-
}
293-
294-
#[cfg(test)]
295-
mod tests {
296-
use super::*;
297-
298-
fn make_entry(name: &str, size: Option<u64>) -> FileEntry {
299-
FileEntry {
300-
name: name.to_string(),
301-
path: format!("/test/{}", name),
302-
is_directory: false,
303-
is_symlink: false,
304-
size,
305-
physical_size: None,
306-
modified_at: None,
307-
created_at: None,
308-
added_at: None,
309-
opened_at: None,
310-
permissions: 0o644,
311-
owner: "user".to_string(),
312-
group: "group".to_string(),
313-
icon_id: "ext:txt".to_string(),
314-
extended_metadata_loaded: true,
315-
recursive_size: None,
316-
recursive_physical_size: None,
317-
recursive_file_count: None,
318-
recursive_dir_count: None,
319-
}
320-
}
321-
322-
#[test]
323-
fn test_compute_diff_addition() {
324-
let old = vec![make_entry("a.txt", Some(100))];
325-
let new = vec![make_entry("a.txt", Some(100)), make_entry("b.txt", Some(200))];
326-
327-
let diff = compute_diff(&old, &new);
328-
assert_eq!(diff.len(), 1);
329-
assert_eq!(diff[0].change_type, "add");
330-
assert_eq!(diff[0].entry.name, "b.txt");
331-
}
332-
333-
#[test]
334-
fn test_compute_diff_removal() {
335-
let old = vec![make_entry("a.txt", Some(100)), make_entry("b.txt", Some(200))];
336-
let new = vec![make_entry("a.txt", Some(100))];
337-
338-
let diff = compute_diff(&old, &new);
339-
assert_eq!(diff.len(), 1);
340-
assert_eq!(diff[0].change_type, "remove");
341-
assert_eq!(diff[0].entry.name, "b.txt");
342-
}
343-
344-
#[test]
345-
fn test_compute_diff_modification() {
346-
let old = vec![make_entry("a.txt", Some(100))];
347-
let new = vec![make_entry("a.txt", Some(200))]; // Size changed
348-
349-
let diff = compute_diff(&old, &new);
350-
assert_eq!(diff.len(), 1);
351-
assert_eq!(diff[0].change_type, "modify");
352-
assert_eq!(diff[0].entry.size, Some(200));
353-
}
354-
355-
#[test]
356-
fn test_compute_diff_no_change() {
357-
let old = vec![make_entry("a.txt", Some(100))];
358-
let new = vec![make_entry("a.txt", Some(100))];
359-
360-
let diff = compute_diff(&old, &new);
361-
assert!(diff.is_empty());
362-
}
363-
}
296+
}

apps/desktop/src-tauri/src/file_system/watcher_test.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ fn test_compute_diff_addition() {
4545
assert_eq!(diff.len(), 1);
4646
assert_eq!(diff[0].change_type, "add");
4747
assert_eq!(diff[0].entry.name, "b.txt");
48+
assert_eq!(diff[0].index, 1); // index in new listing
4849
}
4950

5051
#[test]
@@ -56,6 +57,7 @@ fn test_compute_diff_removal() {
5657
assert_eq!(diff.len(), 1);
5758
assert_eq!(diff[0].change_type, "remove");
5859
assert_eq!(diff[0].entry.name, "b.txt");
60+
assert_eq!(diff[0].index, 1); // index in old listing
5961
}
6062

6163
#[test]
@@ -67,6 +69,7 @@ fn test_compute_diff_modification() {
6769
assert_eq!(diff.len(), 1);
6870
assert_eq!(diff[0].change_type, "modify");
6971
assert_eq!(diff[0].entry.size, Some(200));
72+
assert_eq!(diff[0].index, 0); // index in new listing
7073
}
7174

7275
#[test]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { adjustSelectionIndices } from './adjust-selection-indices'
3+
4+
describe('adjustSelectionIndices', () => {
5+
describe('no-ops', () => {
6+
it('returns empty for empty selection', () => {
7+
expect(adjustSelectionIndices([], [1], [2])).toEqual([])
8+
})
9+
10+
it('returns unchanged indices when no removes or adds', () => {
11+
expect(sorted(adjustSelectionIndices([0, 2, 4], [], []))).toEqual([0, 2, 4])
12+
})
13+
})
14+
15+
describe('add only', () => {
16+
it('shifts selected index when add is before it', () => {
17+
// Old [a,b,c], new [X,a,b,c] → adds=[0]
18+
expect(sorted(adjustSelectionIndices([1], [], [0]))).toEqual([2])
19+
})
20+
21+
it('does not shift selected index when add is after it', () => {
22+
// Old [a,b], new [a,b,X] → adds=[2]
23+
expect(sorted(adjustSelectionIndices([0], [], [2]))).toEqual([0])
24+
})
25+
26+
it('shifts correctly when add is between selected indices', () => {
27+
// Old [a,b,c], new [a,X,b,c] → adds=[1]. Selected {0,2}
28+
expect(sorted(adjustSelectionIndices([0, 2], [], [1]))).toEqual([0, 3])
29+
})
30+
31+
it('handles multiple adds', () => {
32+
// Old [a,b,c], new [X,a,Y,b,c] → adds=[0,2]. Selected {1}
33+
expect(sorted(adjustSelectionIndices([1], [], [0, 2]))).toEqual([3])
34+
})
35+
36+
it('handles add at end of listing', () => {
37+
// Old [a,b], new [a,b,c] → adds=[2]. Selected {0,1}
38+
expect(sorted(adjustSelectionIndices([0, 1], [], [2]))).toEqual([0, 1])
39+
})
40+
41+
it('handles add at beginning of listing', () => {
42+
// Old [b,c], new [a,b,c] → adds=[0]. Selected {0,1}
43+
expect(sorted(adjustSelectionIndices([0, 1], [], [0]))).toEqual([1, 2])
44+
})
45+
})
46+
47+
describe('remove only', () => {
48+
it('shifts selected index when unselected item is removed before it', () => {
49+
// Old [a,b,c], new [b,c] → removes=[0]. Selected {2}
50+
expect(sorted(adjustSelectionIndices([2], [0], []))).toEqual([1])
51+
})
52+
53+
it('deselects removed index and shifts others', () => {
54+
// Old [a,b,c,d], new [a,c,d] → removes=[1]. Selected {1,3}
55+
expect(sorted(adjustSelectionIndices([1, 3], [1], []))).toEqual([2])
56+
})
57+
58+
it('does not shift when remove is after selected index', () => {
59+
// Old [a,b,c], new [a,b] → removes=[2]. Selected {0}
60+
expect(sorted(adjustSelectionIndices([0], [2], []))).toEqual([0])
61+
})
62+
63+
it('handles multiple removes', () => {
64+
// Old [a,b,c,d,e], new [a,d] → removes=[1,2,4]. Selected {0,3}
65+
expect(sorted(adjustSelectionIndices([0, 3], [1, 2, 4], []))).toEqual([0, 1])
66+
})
67+
68+
it('returns empty when all selected indices are removed', () => {
69+
expect(adjustSelectionIndices([1, 3], [1, 3], [])).toEqual([])
70+
})
71+
72+
it('handles remove at beginning of listing', () => {
73+
// Old [a,b,c], new [b,c] → removes=[0]. Selected {0,1,2}
74+
expect(sorted(adjustSelectionIndices([0, 1, 2], [0], []))).toEqual([0, 1])
75+
})
76+
77+
it('handles remove at end of listing', () => {
78+
// Old [a,b,c], new [a,b] → removes=[2]. Selected {0,1,2}
79+
expect(sorted(adjustSelectionIndices([0, 1, 2], [2], []))).toEqual([0, 1])
80+
})
81+
})
82+
83+
describe('mixed adds and removes', () => {
84+
it('handles simultaneous add and remove', () => {
85+
// Old [a,b,c], new [a,X,c] → removes=[1], adds=[1]. Selected {2}
86+
// Interim: s=2, removedBefore=1 → interim=1. Adds=[1]: 1 <= 1+0 → offset=1. Result: 1+1=2
87+
expect(sorted(adjustSelectionIndices([2], [1], [1]))).toEqual([2])
88+
})
89+
90+
it('deselects when selected item is removed and new item is added at same position', () => {
91+
// Old [a,b,c], new [a,X,c] → removes=[1], adds=[1]. Selected {1} (b is removed)
92+
// b was selected, b is removed → deselected. X is a new item, not auto-selected.
93+
expect(adjustSelectionIndices([1], [1], [1])).toEqual([])
94+
})
95+
96+
it('handles add before and remove after selected', () => {
97+
// Old [a,b,c], new [X,a,b] → removes=[2], adds=[0]. Selected {1}
98+
expect(sorted(adjustSelectionIndices([1], [2], [0]))).toEqual([2])
99+
})
100+
})
101+
102+
describe('non-contiguous selection', () => {
103+
it('handles selection with gaps', () => {
104+
// Old [a,b,c,d,e,f,g,h,i,j], selected {1,5,9}
105+
// new listing removes nothing, adds [3] → [a,b,c,X,d,e,f,g,h,i,j]
106+
// Interim: [1,5,9]. Add 3: 3 <= 1? no → emit 1. 3 <= 5? yes, offset=1 → emit 6. 9+1=10 → emit 10.
107+
expect(sorted(adjustSelectionIndices([1, 5, 9], [], [3]))).toEqual([1, 6, 10])
108+
})
109+
})
110+
111+
describe('large selection', () => {
112+
it('handles 1000 selected items with small diff correctly and fast', () => {
113+
// 1000 items selected (0..999), remove indices 100, 500, 900; add at new positions 50, 600
114+
const selected = Array.from({ length: 1000 }, (_, i) => i)
115+
const removes = [100, 500, 900]
116+
const adds = [50, 600]
117+
118+
const start = performance.now()
119+
const result = adjustSelectionIndices(selected, removes, adds)
120+
const elapsed = performance.now() - start
121+
122+
expect(result.length).toBe(997) // 1000 - 3 removed
123+
expect(elapsed).toBeLessThan(50) // should be well under 50ms
124+
125+
// Spot-check: index 0 should become 1 (add at 50 > 0, so offset = 0 initially... let's check)
126+
// Actually index 0: interim=0, add 50 <= 0? no → result 0. That's before any add.
127+
expect(result).toContain(0)
128+
// Index 999 had removes [100,500,900] before it → 3 removed, interim = 996
129+
// Adds [50,600]: 50 <= 996? yes offset=1. 600 <= 997? yes offset=2. Result = 998.
130+
expect(result).toContain(998)
131+
})
132+
})
133+
134+
describe('verified examples from spec', () => {
135+
it('example 1: Old [a,b,c,d,e], new [a,b,X,c,e]', () => {
136+
// removes=[3] (d), adds=[2] (X). Selected {2,3} → {3}
137+
const result = sorted(adjustSelectionIndices([2, 3], [3], [2]))
138+
expect(result).toEqual([3])
139+
})
140+
141+
it('example 2: Old [a,b,c,d,e], new [a,X,b,c,d,e]', () => {
142+
// removes=[], adds=[1]. Selected {0,3} → {0,4}
143+
const result = sorted(adjustSelectionIndices([0, 3], [], [1]))
144+
expect(result).toEqual([0, 4])
145+
})
146+
147+
it('example 3: Old [a,b,c,d,e,f], new [X,a,c,d,Y,f]', () => {
148+
// removes=[1,4] (b,e), adds=[0,4] (X,Y). Selected {1,3,5} → {3,5}
149+
const result = sorted(adjustSelectionIndices([1, 3, 5], [1, 4], [0, 4]))
150+
expect(result).toEqual([3, 5])
151+
})
152+
})
153+
})
154+
155+
function sorted(arr: number[]): number[] {
156+
return [...arr].sort((a, b) => a - b)
157+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Maps selected indices from an old listing to their positions in a new listing,
3+
* given the diff (removed and added indices). Operates in backend index space.
4+
*/
5+
export function adjustSelectionIndices(selectedIndices: number[], removes: number[], adds: number[]): number[] {
6+
if (selectedIndices.length === 0) return []
7+
8+
const sortedRemoves = [...removes].sort((a, b) => a - b)
9+
const sortedAdds = [...adds].sort((a, b) => a - b)
10+
const removeSet = new Set(removes)
11+
12+
// 1. Drop removed indices, compute interim positions (index in the "removes applied" listing)
13+
const interim: number[] = []
14+
for (const s of selectedIndices) {
15+
if (removeSet.has(s)) continue
16+
const removedBefore = countLessThan(sortedRemoves, s)
17+
interim.push(s - removedBefore)
18+
}
19+
20+
if (interim.length === 0) return []
21+
22+
// 2. Adjust for insertions via merge-step
23+
interim.sort((a, b) => a - b)
24+
const result: number[] = []
25+
let addIdx = 0
26+
let offset = 0
27+
28+
for (const pos of interim) {
29+
// Count adds whose new-listing index <= pos + current offset
30+
while (addIdx < sortedAdds.length && sortedAdds[addIdx] <= pos + offset) {
31+
offset++
32+
addIdx++
33+
}
34+
result.push(pos + offset)
35+
}
36+
37+
return result
38+
}
39+
40+
/** Counts how many elements in a sorted array are strictly less than `target`. */
41+
function countLessThan(sorted: number[], target: number): number {
42+
let lo = 0
43+
let hi = sorted.length
44+
while (lo < hi) {
45+
const mid = (lo + hi) >>> 1
46+
if (sorted[mid] < target) lo = mid + 1
47+
else hi = mid
48+
}
49+
return lo
50+
}

0 commit comments

Comments
 (0)