Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
185 changes: 167 additions & 18 deletions Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ struct Agents<VPN: VPNService>: View {
@State private var viewAll = false
@State private var expandedItem: VPNMenuItem.ID?
@State private var hasToggledExpansion: Bool = false
private let defaultVisibleRows = 5
@State private var scrollState = ScrollAffordanceState()
private let defaultVisibleRows = Theme.defaultVisibleAgents

let inspection = Inspection<Self>()

Expand All @@ -17,15 +18,29 @@ struct Agents<VPN: VPNService>: View {
let items = vpn.menuState.sorted
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
ScrollView(showsIndicators: false) {
ForEach(visibleItems, id: \.id) { agent in
MenuItemView(
item: agent,
baseAccessURL: state.baseAccessURL!,
expandedItem: $expandedItem,
userInteracted: $hasToggledExpansion
)
.padding(.horizontal, Theme.Size.trayMargin)
}.onChange(of: visibleItems) {
VStack(spacing: 3) {
ForEach(visibleItems, id: \.id) { agent in
MenuItemView(
item: agent,
baseAccessURL: state.baseAccessURL!,
expandedItem: $expandedItem,
userInteracted: $hasToggledExpansion
)
.padding(.horizontal, Theme.Size.trayMargin)
}
}
.background {
GeometryReader { contentProxy in
Color.clear.preference(
key: ScrollMetricsPreferenceKey.self,
value: ScrollMetrics(
contentMinY: contentProxy.frame(in: .named("agentsScroll")).minY,
contentHeight: contentProxy.size.height
)
)
}
}
.onChange(of: visibleItems) {
// If no workspaces are online, we should expand the first one to come online
if visibleItems.filter({ $0.status != .off }).isEmpty {
hasToggledExpansion = false
Expand All @@ -40,7 +55,31 @@ struct Agents<VPN: VPNService>: View {
hasToggledExpansion = true
}
}
.background {
GeometryReader { containerProxy in
Color.clear.preference(
key: ScrollMetricsPreferenceKey.self,
value: ScrollMetrics(containerHeight: containerProxy.size.height)
)
}
}
.coordinateSpace(name: "agentsScroll")
.mask {
if viewAll {
ScrollAffordanceMask(state: scrollState)
} else {
Color.white
}
}
.overlay {
if viewAll, scrollState.showsTopAffordance || scrollState.showsBottomAffordance {
ScrollAffordanceChevronOverlay(state: scrollState)
}
}
.scrollBounceBehavior(.basedOnSize)
.onPreferenceChange(ScrollMetricsPreferenceKey.self) { metrics in
scrollState = ScrollAffordanceState(metrics: metrics)
}
.frame(maxHeight: 400)
if items.count == 0 {
Text("No workspaces!")
Expand All @@ -49,17 +88,127 @@ struct Agents<VPN: VPNService>: View {
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 2)
}
// Only show the toggle if there are more items to show
if items.count > defaultVisibleRows {
Toggle(isOn: $viewAll) {
Text(viewAll ? "Show less" : "Show all")
.font(.headline)
.foregroundColor(.secondary)
.padding(.horizontal, Theme.Size.trayInset)
.padding(.top, 2)
}.toggleStyle(.button).buttonStyle(.plain)
Button {
viewAll.toggle()
} label: {
ButtonRowView {
HStack(spacing: Theme.Size.trayPadding) {
Text(viewAll ? "Show less" : "Show all")
.font(.subheadline)
.fontWeight(.bold)
Spacer()
Image(systemName: viewAll ? "chevron.up" : "chevron.right")
.font(.system(size: 10, weight: .medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, Theme.Size.trayMargin)
.buttonStyle(.plain)
}
}
}.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
}
}

private struct ScrollAffordanceState: Equatable {
var showsTopAffordance = false
var showsBottomAffordance = false

init() {}

init(metrics: ScrollMetrics) {
let contentHeight = metrics.contentHeight
let containerHeight = metrics.containerHeight
let offsetY = max(-metrics.contentMinY, 0)
let canScroll = contentHeight > containerHeight + 1

guard canScroll else { return }

showsTopAffordance = offsetY > 1
showsBottomAffordance = offsetY + containerHeight < contentHeight - 1
}
}

private struct ScrollMetrics: Equatable {
var contentMinY: CGFloat = 0
var contentHeight: CGFloat = 0
var containerHeight: CGFloat = 0
}

private struct ScrollMetricsPreferenceKey: PreferenceKey {
static let defaultValue = ScrollMetrics()

static func reduce(value: inout ScrollMetrics, nextValue: () -> ScrollMetrics) {
let next = nextValue()
if next.contentHeight != 0 {
value.contentMinY = next.contentMinY
value.contentHeight = next.contentHeight
}
if next.containerHeight != 0 {
value.containerHeight = next.containerHeight
}
}
}

private struct ScrollAffordanceMask: View {
let state: ScrollAffordanceState

var body: some View {
VStack(spacing: 0) {
ScrollAffordanceMaskEdge(direction: .top, isVisible: state.showsTopAffordance)
Color.white
ScrollAffordanceMaskEdge(direction: .bottom, isVisible: state.showsBottomAffordance)
}
.allowsHitTesting(false)
}
}

private struct ScrollAffordanceChevronOverlay: View {
let state: ScrollAffordanceState

var body: some View {
VStack(spacing: 0) {
if state.showsTopAffordance {
Image(systemName: "chevron.up")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
Spacer(minLength: 0)
if state.showsBottomAffordance {
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.bottom, 2)
}
}
.allowsHitTesting(false)
}
}

private struct ScrollAffordanceMaskEdge: View {
enum Direction {
case top
case bottom
}

let direction: Direction
let isVisible: Bool

var body: some View {
Group {
if isVisible {
LinearGradient(
colors: direction == .top ? [.clear, .white] : [.white, .clear],
startPoint: .top,
endPoint: .bottom
)
} else {
Color.white
}
}
.frame(height: 16)
}
}
3 changes: 2 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
}
Divider()
Text("Workspaces")
.font(.headline)
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.secondary)
VPNState<VPN>()
}.padding([.horizontal, .top], Theme.Size.trayInset)
Expand Down
21 changes: 10 additions & 11 deletions Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,27 @@ struct AgentsTests {
}

@Test
func showAllToggle() async throws {
func showAllButton() async throws {
vpn.state = .connected
vpn.menuState = .init(agents: createMockAgents(count: 7))

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
var toggle = try view.find(ViewType.Toggle.self)
var button = try view.find(ViewType.Button.self)
var forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents)
#expect(try toggle.labelView().text().string() == "Show all")
#expect(try !toggle.isOn())
#expect(try button.labelView().find(text: "Show all").string() == "Show all")

try toggle.tap()
toggle = try view.find(ViewType.Toggle.self)
try button.tap()
button = try view.find(ViewType.Button.self)
forEach = try view.find(ViewType.ForEach.self)
#expect(forEach.count == Theme.defaultVisibleAgents + 2)
#expect(try toggle.labelView().text().string() == "Show less")
#expect(try button.labelView().find(text: "Show less").string() == "Show less")

try toggle.tap()
toggle = try view.find(ViewType.Toggle.self)
try button.tap()
button = try view.find(ViewType.Button.self)
forEach = try view.find(ViewType.ForEach.self)
#expect(try toggle.labelView().text().string() == "Show all")
#expect(try button.labelView().find(text: "Show all").string() == "Show all")
#expect(forEach.count == Theme.defaultVisibleAgents)
}
}
Expand All @@ -100,7 +99,7 @@ struct AgentsTests {
vpn.menuState = .init(agents: createMockAgents(count: 3))

#expect(throws: (any Error).self) {
_ = try view.inspect().find(ViewType.Toggle.self)
_ = try view.inspect().find(ViewType.Button.self)
}
}

Expand Down
Loading