Skip to content
Merged
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
5 changes: 5 additions & 0 deletions Sources/Flock/FlockApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,11 @@ struct FlockApp: App {
focusedPaneButton(command)
}
Divider()
Button(ViewCommand.showInWorkspaces.title) { navigator.showInWorkspaces() }
.keyboardShortcut(ViewCommand.showInWorkspaces.shortcut)
.disabled(!navigator.canShowInWorkspaces)
.accessibilityIdentifier(ViewCommand.showInWorkspaces.accessibilityIdentifier)
Divider()
// Move and swap compile the plan the equivalent drag would,
// through the same planner; focus is a click on the neighbor.
ForEach(PaneDirectionCommand.families, id: \.title) { family in
Expand Down
2 changes: 2 additions & 0 deletions Sources/Flock/Ghostty/GhosttyControlSurfaceFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ final class GhosttySessionSurfaceHandle: GhosttyPaneSurface, @unchecked Sendable
session.setParked(false)
}

var isOnScreen: Bool { session.view?.window != nil }

func releaseHerdrHold() {
session.sendHold(.release)
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/Flock/Menus/JumpNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ struct JumpNavigator {
drag.unfocusGridPane()
}

var canShowInWorkspaces: Bool { isFocusedInOverview }

func showInWorkspaces() {
guard isFocusedInOverview, let pane = drag.gridFocusedPane else { return }
jump(to: pane)
}

private func focus(_ pane: PaneID) {
guard viewModel.focusInOverview(pane: pane) else { return }
mode.missionSelection = pane
Expand Down
9 changes: 6 additions & 3 deletions Sources/Flock/Menus/ViewCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ enum ViewCommand: String, CaseIterable {
case newTab, newWorkspace, closeTab, closeWorkspace
case showWorkspaces, showOverview, showArrange
case rearrangeMode, allWorkspaces, openOldestNotification, clearNotifications, commandPalette
case backToOverview, openNextCard
case backToOverview, openNextCard, showInWorkspaces

var title: String {
switch self {
Expand All @@ -21,6 +21,7 @@ enum ViewCommand: String, CaseIterable {
case .openOldestNotification: "Open Oldest Notification"
case .backToOverview: "Back to Overview"
case .openNextCard: "Open Next Card"
case .showInWorkspaces: "Show in Workspaces"
case .clearNotifications: "Clear Notifications"
case .commandPalette: "Command Palette…"
}
Expand All @@ -37,14 +38,15 @@ enum ViewCommand: String, CaseIterable {
case .openOldestNotification: "j"
case .backToOverview: "["
case .openNextCard: "]"
case .showInWorkspaces: "o"
case .clearNotifications: "u"
case .commandPalette: "k"
}
}

var modifiers: EventModifiers {
switch self {
case .newTab, .openOldestNotification, .commandPalette, .backToOverview, .openNextCard: .command
case .newTab, .openOldestNotification, .commandPalette, .backToOverview, .openNextCard, .showInWorkspaces: .command
case .newWorkspace, .closeTab, .clearNotifications: [.command, .shift]
case .closeWorkspace: [.command, .option, .shift]
case .showWorkspaces, .showOverview, .showArrange: .command
Expand All @@ -61,7 +63,7 @@ enum ViewCommand: String, CaseIterable {
PaletteSurface.everywhere.subtracting(PaletteSurface.surfaces(of: viewTab ?? .workspaces))
case .newTab, .newWorkspace, .closeTab, .closeWorkspace, .rearrangeMode, .allWorkspaces: [.workspaces]
case .openOldestNotification, .clearNotifications: PaletteSurface.everywhere
case .backToOverview, .openNextCard: [.overviewPane]
case .backToOverview, .openNextCard, .showInWorkspaces: [.overviewPane]
case .commandPalette: []
}
}
Expand Down Expand Up @@ -99,6 +101,7 @@ enum ViewCommand: String, CaseIterable {
case .openOldestNotification: "flock.view.openOldestNotification"
case .backToOverview: "flock.view.backToOverview"
case .openNextCard: "flock.view.openNextCard"
case .showInWorkspaces: "flock.pane.showInWorkspaces"
case .clearNotifications: "flock.view.clearNotifications"
case .commandPalette: "flock.view.commandPalette"
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Flock/Palette/PaletteCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ enum PaletteCatalog {
var commands: [(PaletteNamespace, ViewCommand)] = ViewTab.allCases.map { (.view, ViewCommand.show($0)) }
commands += [(.view, .rearrangeMode), (.view, .allWorkspaces)]
if context.hasNotifications { commands += [(.view, .openOldestNotification), (.view, .clearNotifications)] }
commands.append((.view, .backToOverview))
commands += [(.view, .backToOverview), (.view, .showInWorkspaces)]
if context.hasNextCard { commands.append((.view, .openNextCard)) }
if !context.topBarOverlayUp {
if context.hasSelectedWorkspace { commands += [(.tab, .newTab), (.tab, .closeTab)] }
Expand Down
2 changes: 2 additions & 0 deletions Sources/Flock/Palette/PaletteRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ struct PaletteRunner {
JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).backToOverview()
case .openNextCard:
JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).openNext()
case .showInWorkspaces:
JumpNavigator(viewModel: viewModel, drag: dragCoordinator, mode: modeStore).showInWorkspaces()
case .clearNotifications: viewModel.clearAttentionToasts()
case .commandPalette: break
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Flock/Theme/ChromeMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,10 @@ enum ChromeMetrics {
/// and its heading and labels are pulled in to match, so their
/// leading edges line up with the groups'.
static let laneScrollInset: CGFloat = 3
/// A lane's scroll view reaches this far into the lane's trailing
/// padding, and its content gives it back, so the scroller runs in
/// the padding instead of over the cards.
static let laneScrollerGutter: CGFloat = lanePadding - laneScrollInset
static let coolingOpacity: Double = 0.75
static let timelineWidth: CGFloat = 180
/// A narrow window's lanes give the timeline up before `repo @
Expand Down
3 changes: 2 additions & 1 deletion Sources/Flock/Views/MissionControl/MissionControlView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,16 @@ struct MissionControlView: View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: M.cardGap) { cards }
.padding(M.laneScrollInset)
.padding(.trailing, M.laneScrollerGutter)
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollIndicators(.never)
.scrollBounceBehavior(.basedOnSize, axes: .vertical)
// Clipped top and bottom only: a card moving to another lane
// is drawn by the lane it lands in, and has to stay visible
// as it crosses from its old lane.
.scrollClipDisabled()
.mask { Rectangle().padding(.horizontal, -M.crossLaneReach) }
.laneScroller(theme: theme)
.onAppear { if let selected = mode.missionSelection { reader.scrollTo(selected) } }
.onChange(of: mode.missionSelection) { _, selected in
if let selected { withAnimation { reader.scrollTo(selected) } }
Expand Down
15 changes: 13 additions & 2 deletions Sources/Flock/Views/MissionControl/MissionSplitLaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ struct SplitLaneBody<Content: View>: View {
memory.record($0, in: \.labelHeight, for: slot.kind)
}
.accessibilityIdentifier("flock.mission.subgroup.\(slot.kind.rawValue)")
SubgroupScroll(memory: memory, kind: slot.kind, cards: slot.cards, selection: selection) { content(slot.kind) }
SubgroupScroll(theme: theme, memory: memory, kind: slot.kind, cards: slot.cards, selection: selection) { content(slot.kind) }
.frame(height: scrollHeight)
}
}
Expand All @@ -132,6 +132,7 @@ struct SplitLaneBody<Content: View>: View {
/// One subgroup's scroll area. Its content is measured inside the scroll
/// view, where its height does not depend on the frame the split gives it.
private struct SubgroupScroll<Content: View>: View {
let theme: Theme
let memory: MissionLaneMemory
let kind: MissionSubgroup
let cards: [PaneID]
Expand All @@ -147,6 +148,7 @@ private struct SubgroupScroll<Content: View>: View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: M.cardGap) { content() }
.padding(M.laneScrollInset)
.padding(.trailing, M.laneScrollerGutter)
.frame(maxWidth: .infinity, alignment: .leading)
.coordinateSpace(kind.space)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
Expand All @@ -157,13 +159,13 @@ private struct SubgroupScroll<Content: View>: View {
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, offset in
memory.offsets[kind] = offset
}
.scrollIndicators(.never)
.scrollBounceBehavior(.basedOnSize, axes: .vertical)
// Clipped top and bottom only: a card moving to another lane is
// drawn by the subgroup it lands in, and has to stay visible as
// it crosses from its old lane.
.scrollClipDisabled()
.mask { Rectangle().padding(.horizontal, -M.crossLaneReach) }
.laneScroller(theme: theme)
.onAppear {
if let offset = memory.offsets[kind] { position.scrollTo(y: offset) }
guard let selection, cards.contains(selection) else { return }
Expand All @@ -179,6 +181,15 @@ private struct SubgroupScroll<Content: View>: View {
}

extension View {
/// A lane's scroll view, out through the lane's trailing padding, with
/// its scroller drawn for the theme's ground: the window's appearance
/// follows the system, so a dark theme otherwise gets a dark scroller.
/// Its content pads `laneScrollerGutter` back on the trailing side.
func laneScroller(theme: Theme) -> some View {
padding(.trailing, -ChromeMetrics.MissionControl.laneScrollerGutter)
.environment(\.colorScheme, ChromeRoles.isLight(panelBg: theme.palette.panelBg) ? .light : .dark)
}

/// Reports a subgroup's first card's bottom, which with its label is the
/// least of the subgroup a split may show.
func missionSubgroupFloor(_ subgroup: MissionSubgroup?, in memory: MissionLaneMemory) -> some View {
Expand Down
5 changes: 5 additions & 0 deletions Sources/FlockCore/Ghostty/GhosttyPaneAttaching.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ public protocol GhosttyPaneSurface: AnyObject, Sendable {
/// parked is a harmless no-op.
func unpark()

/// Whether the surface's view is in a window right now. A pane handed
/// from one host to another gets the old host's park after the new
/// host's attach, and only this tells that park apart from a real one.
var isOnScreen: Bool { get }

/// Drops flock's herdr control client for this pane, which drops the
/// pane's `direct_attach_resize_lock` with it, so herdr sizes the pane for
/// its own shell clients again. The surface, its PTY, its scrollback, the
Expand Down
2 changes: 1 addition & 1 deletion Sources/FlockCore/ViewModels/SessionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1356,7 +1356,7 @@ public final class SessionViewModel {
/// difference between this and `performTeardown`. Evicts the warm cap's
/// overflow afterward, oldest park first.
private func performPark(pane: PaneID) async {
guard let surface = ghosttySurfaces[pane] else { return }
guard let surface = ghosttySurfaces[pane], !surface.isOnScreen else { return }
surface.park()
paneScrollSubscriber?.unsubscribe(pane: pane)
parkedPanes.removeAll { $0 == pane }
Expand Down
1 change: 1 addition & 0 deletions Tests/FlockChromeRender/ArrangeRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ private final class ArrangeGroundSurface: GhosttyPaneSurface {
func detach() async {}
func park() {}
func unpark() {}
var isOnScreen: Bool { false }
func releaseHerdrHold() {}
func takeHerdrHold() {}
var cellHeight: CGFloat? { 18 }
Expand Down
20 changes: 20 additions & 0 deletions Tests/FlockChromeRender/ChromeRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,25 @@ final class ChromeRenderTests: XCTestCase {
window.close()
}

/// Show in Workspaces leaves Overview for the focused pane's own tab,
/// with that pane focused.
func testShowInWorkspacesOpensTheFocusedPanesTab() async throws {
let client = MethodRecordingClient()
let (harness, window) = try await focusedOverview(theme: .tokyoNight, client: client)
let navigator = JumpNavigator(viewModel: harness.viewModel, drag: harness.drag, mode: harness.modeStore)
navigator.open(pane: GridFixture.buildPane)
await settle(window)
XCTAssertTrue(navigator.canShowInWorkspaces)
navigator.showInWorkspaces()
await settle(window)
XCTAssertFalse(harness.drag.isGridShown)
XCTAssertNil(harness.drag.gridFocusedPane)
let methods = await client.methods
XCTAssertTrue(methods.contains("tab.focus") && methods.contains("pane.focus"), "herdr was not moved to the pane: \(methods)")
XCTAssertFalse(navigator.canShowInWorkspaces, "only the focused view has a pane to show")
window.close()
}

/// The jump key in the focused view swaps in the next oldest card, and
/// with none left it leaves the view as it is.
func testJInTheFocusedViewOpensTheNextCard() async throws {
Expand Down Expand Up @@ -4960,6 +4979,7 @@ private final class GroundSurface: GhosttyPaneSurface {
func detach() async {}
func park() {}
func unpark() {}
var isOnScreen: Bool { false }
func releaseHerdrHold() {}
func takeHerdrHold() {}
var cellHeight: CGFloat? { 18 }
Expand Down
1 change: 1 addition & 0 deletions Tests/FlockChromeRender/PaletteShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class PaletteShortcutTests: XCTestCase {
XCTAssertFalse(ViewCommand.allCases.contains { $0.key == "j" && $0.modifiers == [.command, .shift] }, "Jump Back is retired")
XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.backToOverview.key, modifiers: ViewCommand.backToOverview.modifiers), "⌘[")
XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.openNextCard.key, modifiers: ViewCommand.openNextCard.modifiers), "⌘]")
XCTAssertEqual(ShortcutLabel.text(key: ViewCommand.showInWorkspaces.key, modifiers: ViewCommand.showInWorkspaces.modifiers), "⌘O")
XCTAssertEqual(ViewCommand.clearNotifications.key, "u")
XCTAssertEqual(ViewCommand.clearNotifications.modifiers, [.command, .shift])
}
Expand Down
1 change: 1 addition & 0 deletions Tests/FlockChromeRender/RtModalChromeRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,7 @@ private final class GroundSurface: GhosttyPaneSurface {
func detach() async {}
func park() {}
func unpark() {}
var isOnScreen: Bool { false }
func releaseHerdrHold() {}
func takeHerdrHold() {}
var cellHeight: CGFloat? { 18 }
Expand Down
1 change: 1 addition & 0 deletions Tests/FlockChromeRender/TopBarRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ private final class OverlaySurface: GhosttyPaneSurface {
func detach() async {}
func park() {}
func unpark() {}
var isOnScreen: Bool { false }
func releaseHerdrHold() {}
func takeHerdrHold() {}
var cellHeight: CGFloat? { nil }
Expand Down
1 change: 1 addition & 0 deletions Tests/FlockChromeRender/WorkspaceClickLatencyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ private final class GroundSurface: GhosttyPaneSurface {
func detach() async {}
func park() {}
func unpark() {}
var isOnScreen: Bool { false }
func releaseHerdrHold() {}
func takeHerdrHold() {}
var cellHeight: CGFloat? { 18 }
Expand Down
23 changes: 23 additions & 0 deletions Tests/FlockCoreTests/SessionViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ private final class FakeGhosttyPaneSurface: GhosttyPaneSurface, @unchecked Senda
var hasFirstFrame = false
var hasClaimedMouse = false
var programHasMouse = false
var isOnScreen = false

init(pane: PaneID) {
self.pane = pane
Expand Down Expand Up @@ -1856,6 +1857,28 @@ final class SessionViewModelTests: XCTestCase {
XCTAssertNotNil(viewModel.ghosttySurface(for: pane), "a parked surface must still be reachable -- it is what makes a warm reattach possible")
}

/// A pane handed from one host to another (Overview's focused view to the
/// Workspaces canvas) gets the old host's park after the new host's
/// attach. The surface is on screen by then, and a park would stop it
/// drawing while it still takes keys.
@MainActor
func testAParkThatLandsWhileThePaneIsOnScreenLeavesItDrawing() async throws {
let factory = FakeGhosttyPaneFactory()
let viewModel = SessionViewModel(client: RecordingCommandClient(), ghosttyFactory: factory)
let pane = PaneID(rawValue: "w1:p1")
_ = await viewModel.attachPane(pane)
let surface = try XCTUnwrap(factory.surfaces[pane])
surface.isOnScreen = true

await viewModel.detachPane(pane)

XCTAssertEqual(surface.parkCallCount, 0)
let otherPanes = (2...13).map { PaneID(rawValue: "w1:p\($0)") }
for other in otherPanes { _ = await viewModel.attachPane(other) }
for other in otherPanes { await viewModel.detachPane(other) }
XCTAssertEqual(surface.detachCallCount, 0, "a pane on screen is never in the warm queue")
}

/// Reattaching a PARKED pane returns the SAME surface, with no second
/// `makeSurface` call -- the whole point of the pool: a tab switch back
/// re-hosts warm content instead of spawning a fresh bridge/PTY.
Expand Down
Loading