Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 127 additions & 43 deletions snapo-app-mac/Snap-O/NetworkInspector/NetworkInspectorSidebarList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,65 +7,68 @@ struct NetworkInspectorSidebarList: View {
let filteredItems: [NetworkInspectorListItemViewModel]
let selectedServer: NetworkInspectorServerViewModel?
@Binding var selectedItem: NetworkInspectorItemID?
@State private var isScrolledToTop = true
@State private var isScrolledToBottom = true

var body: some View {
List(selection: $selectedItem) {
if items.isEmpty {
Text("No activity yet")
.foregroundStyle(.secondary)
} else if serverScopedItems.isEmpty {
Text(statusPlaceholder)
.foregroundStyle(.secondary)
} else if filteredItems.isEmpty {
Text("No matches")
.foregroundStyle(.secondary)
} else {
ForEach(filteredItems) { item in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
Text(item.primaryPathComponent)
.font(.subheadline.weight(.medium))
.lineLimit(1)
if !item.secondaryPath.isEmpty {
Text(item.secondaryPath)
.font(.caption)
.foregroundStyle(.secondary)
ScrollViewReader { proxy in
List(selection: $selectedItem) {
if let placeholder = placeholderText {
placeholderRow(placeholder)
} else {
ForEach(filteredItems) { item in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
Text(item.primaryPathComponent)
.font(.subheadline.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
if !item.secondaryPath.isEmpty {
Text(item.secondaryPath)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}

Spacer()
Spacer()

Text(item.method)
.font(.system(.caption, design: .monospaced))
.bold()
.foregroundStyle(.secondary)
Text(item.method)
.font(.system(.caption, design: .monospaced))
.bold()
.foregroundStyle(.secondary)

statusView(for: item)
}
}
.contentShape(Rectangle())
.contextMenu {
if case .request(let request) = item.kind {
Button("Copy URL") {
NetworkInspectorCopyExporter.copyURL(request.url)
statusView(for: item)
}
}
.contentShape(Rectangle())
.contextMenu {
if case .request(let request) = item.kind {
Button("Copy URL") {
NetworkInspectorCopyExporter.copyURL(request.url)
}

Button("Copy as cURL") {
if let model = store.requestViewModel(for: request.id) {
NetworkInspectorCopyExporter.copyCurl(for: model)
Button("Copy as cURL") {
if let model = store.requestViewModel(for: request.id) {
NetworkInspectorCopyExporter.copyCurl(for: model)
}
}
}
}
.tag(item.id)
.background(alignment: .topLeading) {
edgeVisibilityMarkers(for: item.id)
}
}
.tag(item.id)
}
}
.listStyle(.sidebar)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onChange(of: filteredItems.map(\.id)) { previous, current in
handleListChange(previous: previous, current: current, proxy: proxy)
}
}
.listStyle(.sidebar)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

@ViewBuilder
Expand Down Expand Up @@ -116,10 +119,91 @@ struct NetworkInspectorSidebarList: View {
}

private extension NetworkInspectorSidebarList {
var firstItemID: NetworkInspectorItemID? {
filteredItems.first?.id
}

var lastItemID: NetworkInspectorItemID? {
filteredItems.last?.id
}

var isShowingItems: Bool {
!items.isEmpty && !serverScopedItems.isEmpty && !filteredItems.isEmpty
}

var placeholderText: String? {
if items.isEmpty { return "No activity yet" }
if serverScopedItems.isEmpty { return statusPlaceholder }
if filteredItems.isEmpty { return "No matches" }
return nil
}

var statusPlaceholder: String {
if selectedServer?.hasHello == true {
return "No activity for this app yet"
}
return "Waiting for connection…"
}

@ViewBuilder
func placeholderRow(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.onAppear(perform: resetEdgePositions)
}

@ViewBuilder
func edgeVisibilityMarkers(for id: NetworkInspectorItemID) -> some View {
if id == firstItemID {
EdgeVisibilityDetector { isScrolledToTop = $0 }
}

if id == lastItemID {
EdgeVisibilityDetector { isScrolledToBottom = $0 }
}
}

func handleListChange(
previous: [NetworkInspectorItemID],
current: [NetworkInspectorItemID],
proxy: ScrollViewProxy
) {
guard isShowingItems, !current.isEmpty,
!Set(current).subtracting(previous).isEmpty
else { return }

let anchor: UnitPoint
let targetID: NetworkInspectorItemID?

switch store.listSortOrder {
case .newestFirst where isScrolledToTop:
anchor = .top
targetID = current.first
case .oldestFirst where isScrolledToBottom:
anchor = .bottom
targetID = current.last
default:
return
}

if let targetID {
proxy.scrollTo(targetID, anchor: anchor)
}
}

func resetEdgePositions() {
isScrolledToTop = true
isScrolledToBottom = true
}
}

private struct EdgeVisibilityDetector: View {
var onVisibilityChange: (Bool) -> Void

var body: some View {
Color.clear
.frame(width: 1, height: 1)
.onAppear { onVisibilityChange(true) }
.onDisappear { onVisibilityChange(false) }
}
}