Skip to content

Commit 25c4bd2

Browse files
author
Iakov Senatov
committed
perf: fix 3 root causes of navigation lag on 26k-file directories
1. PanelFileTableSection: remove StableKeyView wrapper + duplicate .id() modifier Both caused FileTableView identity reset on every AppState update, forcing ColumnLayoutModel.init() + load() on every keypress 2. ColumnLayoutModel: move ownership from FileTableView (@State) to PanelFileTableSection. Passed as @binding — survives files list updates. init() now fires exactly once per panel (was: every body evaluation) 3. DualDirectoryScanner: timer deadline .now() -> .now() + refreshInterval Prevents double full-scan at startup (timer + explicit refreshFiles racing) Diagnostics added: - [ColumnLayout] init() — logs when ColumnLayoutModel is constructed - [Cache] assign+rebuild Nms / sort+rebuild Nms — time for index rebuild - [Nav] selectAndScroll=Nms — time for selection + scroll update - [Scanner] Full update (Nms since last) — interval between list pushes to UI
1 parent 04f7291 commit 25c4bd2

File tree

6 files changed

+77
-57
lines changed

6 files changed

+77
-57
lines changed

GUI/Sources/Features/Panels/FileTable/ColumnLayoutModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ final class ColumnLayoutModel {
136136
init(panelSide: PanelSide) {
137137
self.storageKey = "ColumnLayout.\(panelSide.rawValue)"
138138
self.columns = Self.defaultOrder.map { ColumnSpec(id: $0) }
139+
log.debug("[ColumnLayout] init(panelSide:\(panelSide)) — should happen once per panel")
139140
load()
140141
}
141142

GUI/Sources/Features/Panels/FileTable/FileTableView.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,24 @@ struct FileTableView: View {
4848
/// O(1) scroll target — set by keyboard nav, consumed by ScrollView(.scrollPosition)
4949
@State var scrollAnchorID: CustomFile.ID? = nil
5050

51-
// MARK: - Column Layout (replaces individual CGFloat @State for each column)
52-
@State var layout: ColumnLayoutModel
51+
// MARK: - Column Layout — owned by PanelFileTableSection, passed as Binding to avoid recreating on list updates
52+
@Binding var layout: ColumnLayoutModel
5353

5454
// MARK: - Init
55-
init(panelSide: PanelSide, files: [CustomFile], selectedID: Binding<CustomFile.ID?>,
56-
onSelect: @escaping (CustomFile) -> Void, onDoubleClick: @escaping (CustomFile) -> Void) {
55+
init(
56+
panelSide: PanelSide,
57+
files: [CustomFile],
58+
selectedID: Binding<CustomFile.ID?>,
59+
layout: Binding<ColumnLayoutModel>,
60+
onSelect: @escaping (CustomFile) -> Void,
61+
onDoubleClick: @escaping (CustomFile) -> Void
62+
) {
5763
self.panelSide = panelSide
5864
self.files = files
5965
self._selectedID = selectedID
66+
self._layout = layout
6067
self.onSelect = onSelect
6168
self.onDoubleClick = onDoubleClick
62-
self._layout = State(initialValue: ColumnLayoutModel(panelSide: panelSide))
6369
}
6470

6571
// MARK: - Computed Properties

GUI/Sources/Features/Panels/FileTable/TableKeyboardNavigation.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,14 @@ struct TableKeyboardNavigation {
9090
}
9191

9292
private func selectAndScroll(at index: Int) {
93+
let t0 = Date()
9394
let file = files[index]
9495
selectedID.wrappedValue = file.id
9596
onSelect(file)
9697
// O(1): SwiftUI uses index * rowHeight — no cell materialization
9798
scrollAnchorID.wrappedValue = file.id
98-
log.debug("[TableKeyboardNavigation] idx=\(index) file=\(file.nameStr)")
99+
let ms = Int(Date().timeIntervalSince(t0) * 1000)
100+
log.debug("[Nav] idx=\(index) name=\(file.nameStr) selectAndScroll=\(ms)ms indexSize=\(files.count)")
99101
}
100102
}
101103

GUI/Sources/Features/Panels/FileTable/TableView/FileTableView+State.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,20 @@ extension FileTableView {
1616
// (onChange of sortKey/bSortAscending). On plain files change the order
1717
// is already correct — just assign without re-sorting to avoid blocking
1818
// MainActor for ~100ms on 26k-item directories.
19+
let t0 = Date()
1920
cachedSortedFiles = files
2021
rebuildIndexByID()
21-
log.debug("\(#function) panel=\(panelSide) cached \(cachedSortedFiles.count) items")
22+
let ms = Int(Date().timeIntervalSince(t0) * 1000)
23+
log.debug("[Cache] panel=\(panelSide) assign+rebuild \(cachedSortedFiles.count) items in \(ms)ms")
2224
}
2325

2426
/// Called only when sort parameters change — re-sort needed.
2527
func recomputeSortedCacheForSortChange() {
28+
let t0 = Date()
2629
cachedSortedFiles = files.sorted(by: sorter.compare)
2730
rebuildIndexByID()
28-
log.debug("\(#function) panel=\(panelSide) re-sorted \(cachedSortedFiles.count) by \(sortKey) asc=\(sortAscending)")
31+
let ms = Int(Date().timeIntervalSince(t0) * 1000)
32+
log.debug("[Cache] panel=\(panelSide) sort+rebuild \(cachedSortedFiles.count) items in \(ms)ms")
2933
}
3034

3135
/// Rebuilds the O(1) lookup dictionary and the rows array after list changes. Called only on list update.

GUI/Sources/Features/Panels/PanelFileTableSection.swift

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,61 +19,57 @@ struct PanelFileTableSection: View {
1919
let onSelect: (CustomFile) -> Void
2020
let onDoubleClick: (CustomFile) -> Void
2121
@State private var rowRects: [CustomFile.ID: CGRect] = [:]
22+
// Owned here so it survives files-list updates without recreating
23+
@State private var columnLayout: ColumnLayoutModel
24+
25+
// MARK: - Init
26+
init(
27+
files: [CustomFile],
28+
selectedID: Binding<CustomFile.ID?>,
29+
panelSide: PanelSide,
30+
onPanelTap: @escaping (PanelSide) -> Void,
31+
onSelect: @escaping (CustomFile) -> Void,
32+
onDoubleClick: @escaping (CustomFile) -> Void
33+
) {
34+
self.files = files
35+
self._selectedID = selectedID
36+
self.panelSide = panelSide
37+
self.onPanelTap = onPanelTap
38+
self.onSelect = onSelect
39+
self.onDoubleClick = onDoubleClick
40+
self._columnLayout = State(initialValue: ColumnLayoutModel(panelSide: panelSide))
41+
}
2242

2343
var body: some View {
24-
// Use content-based key to ensure re-render when files change
25-
// Combines: count + first file name + last file name + panel side
26-
let contentKey = makeContentKey()
27-
28-
StableKeyView(contentKey) {
29-
FileTableView(
30-
panelSide: panelSide,
31-
files: files,
32-
selectedID: $selectedID,
33-
onSelect: handleSelection,
34-
onDoubleClick: onDoubleClick
35-
)
36-
.contentShape(Rectangle())
37-
.simultaneousGesture(
38-
TapGesture(count: 1)
39-
.onEnded {
40-
if appState.focusedPanel != panelSide {
41-
appState.focusedPanel = panelSide
42-
}
44+
FileTableView(
45+
panelSide: panelSide,
46+
files: files,
47+
selectedID: $selectedID,
48+
layout: $columnLayout,
49+
onSelect: handleSelection,
50+
onDoubleClick: onDoubleClick
51+
)
52+
.contentShape(Rectangle())
53+
.simultaneousGesture(
54+
TapGesture(count: 1)
55+
.onEnded {
56+
if appState.focusedPanel != panelSide {
57+
appState.focusedPanel = panelSide
4358
}
44-
)
45-
.coordinateSpace(name: "fileTableSpace")
46-
.onPreferenceChange(RowRectPreference.self) { value in
47-
if value != rowRects {
48-
rowRects = value
4959
}
60+
)
61+
.coordinateSpace(name: "fileTableSpace")
62+
.onPreferenceChange(RowRectPreference.self) { value in
63+
if value != rowRects {
64+
rowRects = value
5065
}
51-
.animation(nil, value: selectedID)
52-
.transaction { txn in
53-
txn.disablesAnimations = true
54-
}
55-
.id("PFTS_\(panelSide)_\(contentKey)")
5666
}
57-
}
58-
59-
// MARK: - Generate content-based key for StableBy
60-
private func makeContentKey() -> Int {
61-
var hasher = Hasher()
62-
hasher.combine(panelSide)
63-
hasher.combine(files.count)
64-
// Include first and last file names to detect content changes
65-
if let first = files.first {
66-
hasher.combine(first.nameStr)
67-
}
68-
if let last = files.last {
69-
hasher.combine(last.nameStr)
67+
.animation(nil, value: selectedID)
68+
.transaction { txn in
69+
txn.disablesAnimations = true
7070
}
71-
// Include modification time of first file to detect rename
72-
if let first = files.first {
73-
hasher.combine(first.pathStr)
74-
}
75-
return hasher.finalize()
7671
}
72+
7773

7874
// MARK: - Selection handler
7975
private func handleSelection(_ file: CustomFile) {

GUI/Sources/Services/Scanner/DualDirectoryScanner.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ actor DualDirectoryScanner {
158158

159159
private func setupTimer(for side: PanelSide) {
160160
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
161-
timer.schedule(deadline: .now(), repeating: .seconds(refreshInterval))
161+
// Start after first interval — avoids double scan at startup (refreshFiles already called explicitly)
162+
timer.schedule(deadline: .now() + .seconds(refreshInterval), repeating: .seconds(refreshInterval))
162163
timer.setEventHandler { [weak self] in
163164
guard let self else { return }
164165
Task { @MainActor in
@@ -254,13 +255,23 @@ actor DualDirectoryScanner {
254255
// MARK: - Update displayed files (full replace — used by polling timer)
255256
// files arrive pre-sorted from Task.detached — no sort on MainActor
256257

258+
private var lastUpdateTime: [PanelSide: Date] = [:]
259+
257260
@MainActor
258261
private func updateScannedFiles(_ sortedFiles: [CustomFile], for side: PanelSide) {
262+
let now = Date()
263+
let sinceLastMs: String
264+
if let prev = lastUpdateTime[side] {
265+
sinceLastMs = "\(Int(now.timeIntervalSince(prev) * 1000))ms since last"
266+
} else {
267+
sinceLastMs = "first update"
268+
}
269+
lastUpdateTime[side] = now
259270
switch side {
260271
case .left: appState.displayedLeftFiles = sortedFiles
261272
case .right: appState.displayedRightFiles = sortedFiles
262273
}
263-
log.debug("[Scanner] Full update \(side) panel: \(sortedFiles.count) items")
274+
log.debug("[Scanner] Full update \(side): \(sortedFiles.count) items (\(sinceLastMs))")
264275
}
265276

266277
// MARK: - Reset timer for a panel

0 commit comments

Comments
 (0)