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
79 changes: 74 additions & 5 deletions .agents/skills/write-stable-tests/scripts/test-timing-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,44 @@ function processGroupIsRunning(processID) {
}
}

function processIsRunning(processID) {
try {
process.kill(processID, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}

function addDescendantProcessIDs(rootProcessID, processIDs) {
let rows;
try {
const listing = spawnSync("ps", ["-axo", "pid=,ppid="], {
encoding: "utf8",
timeout: 5000,
});
if (listing.status !== 0) return;
rows = listing.stdout
.split("\n")
.map((line) => line.trim().split(/\s+/).map(Number))
.filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid));
} catch {
// The direct process group remains the portable fallback when ps is unavailable.
return;
}

processIDs.add(rootProcessID);
let changed = true;
while (changed) {
changed = false;
for (const [pid, ppid] of rows) {
if (!processIDs.has(ppid) || processIDs.has(pid)) continue;
processIDs.add(pid);
changed = true;
}
}
}

function signalProcessGroup(child, signal) {
try {
process.kill(-child.pid, signal);
Expand All @@ -27,12 +65,30 @@ function signalProcessGroup(child, signal) {
}
}

async function waitForProcessGroupExit(processID, timeoutMs, pollIntervalMs) {
function signalDescendantProcesses(rootProcessID, processIDs, signal) {
// The process-group signal handles ordinary descendants. Signal every known
// non-root PID as well because swift-testing may create a new process group.
for (const processID of processIDs) {
if (processID === rootProcessID) continue;
try {
process.kill(processID, signal);
} catch {
// It either exited between the snapshot and signal or is already gone.
}
}
}

function processTreeIsRunning(processID, processIDs) {
return processGroupIsRunning(processID)
|| [...processIDs].some((candidate) => processIsRunning(candidate));
}

async function waitForProcessTreeExit(processID, processIDs, timeoutMs, pollIntervalMs) {
const deadline = performance.now() + timeoutMs;
while (processGroupIsRunning(processID) && performance.now() < deadline) {
while (processTreeIsRunning(processID, processIDs) && performance.now() < deadline) {
await delay(pollIntervalMs);
}
return !processGroupIsRunning(processID);
return !processTreeIsRunning(processID, processIDs);
}

export async function terminateProcessTree(
Expand All @@ -52,10 +108,23 @@ export async function terminateProcessTree(
return true;
}

const processIDs = new Set();
addDescendantProcessIDs(child.pid, processIDs);
signalProcessGroup(child, "SIGTERM");
if (await waitForProcessGroupExit(child.pid, gracePeriodMs, pollIntervalMs)) return true;
signalDescendantProcesses(child.pid, processIDs, "SIGTERM");
if (await waitForProcessTreeExit(child.pid, processIDs, gracePeriodMs, pollIntervalMs)) return true;

// Refresh before forcing termination so descendants created during graceful
// shutdown cannot escape the cleanup pass.
addDescendantProcessIDs(child.pid, processIDs);
signalProcessGroup(child, "SIGKILL");
return waitForProcessGroupExit(child.pid, forcedTerminationTimeoutMs, pollIntervalMs);
signalDescendantProcesses(child.pid, processIDs, "SIGKILL");
return waitForProcessTreeExit(
child.pid,
processIDs,
forcedTerminationTimeoutMs,
pollIntervalMs,
);
}

function lineCollector(callback) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,10 +566,13 @@ try {
if (process.platform !== "win32") {
let rootPID = null;
let descendantPID = null;
// swift-testing may detach its helper into a separate process group. The
// timeout owner must still discover and terminate that descendant.
const descendantSource = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
const rootSource = `
const { spawn } = require("node:child_process");
const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(descendantSource)}], {
detached: true,
stdio: "ignore",
});
console.log(descendant.pid);
Expand Down
158 changes: 127 additions & 31 deletions macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct BranchSwitcherPopover: View {
@Binding var isPresented: Bool
let onCommit: () -> Void
let onPush: (GitReference) -> Void
let onDelete: (GitReference) -> Void
let onNewBranch: (GitReference) -> Void
let onCheckoutRevision: () -> Void
let onManageBranches: () -> Void
Expand Down Expand Up @@ -364,53 +365,117 @@ struct BranchSwitcherPopover: View {
.lithePointer()
}

/// A branch line. Clicking it opens the reference's action menu instead of
/// checking out directly, matching IDEA: checkout is an explicit menu entry,
/// so a stray click on the list can never switch the working tree.
private func branchRow(
_ reference: GitReference,
indented: Bool,
presentation: BranchRowPresentation
) -> some View {
let highlightsCurrent = presentation == .recent && reference.isCurrent

return Button {
guard !reference.isCurrent else { return }
isPresented = false
Task { await model.checkoutReference(reference) }
} label: {
HStack(spacing: 8) {
Image(systemName: referenceIcon(reference, marksCurrent: presentation == .recent))
.font(.system(size: 11.5))
.foregroundStyle(highlightsCurrent ? LitheTheme.warning : LitheTheme.secondaryText)
.frame(width: 17)
Text(branchDisplayName(reference, presentation: presentation))
.font(.system(size: 12.5))
.foregroundStyle(LitheTheme.primaryText)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 10)
if let upstream = reference.upstreamShortName {
Text(upstream)
return BranchActionMenuRow(
label: {
HStack(spacing: 8) {
Image(systemName: referenceIcon(reference, marksCurrent: presentation == .recent))
.font(.system(size: 11.5))
.foregroundStyle(LitheTheme.secondaryText)
.foregroundStyle(highlightsCurrent ? LitheTheme.warning : LitheTheme.secondaryText)
.frame(width: 17)
Text(branchDisplayName(reference, presentation: presentation))
.font(.system(size: 12.5))
.foregroundStyle(LitheTheme.primaryText)
.lineLimit(1)
.truncationMode(.middle)
}
if !reference.isCurrent {
Spacer(minLength: 10)
if let upstream = reference.upstreamShortName {
Text(upstream)
.font(.system(size: 11.5))
.foregroundStyle(LitheTheme.secondaryText)
.lineLimit(1)
.truncationMode(.middle)
}
Image(systemName: "chevron.right")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(LitheTheme.secondaryText)
}
.padding(.leading, branchRowLeadingPadding(indented: indented, presentation: presentation))
.padding(.trailing, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Metrics.branchRowHeight)
.background(highlightsCurrent ? LitheTheme.subtleSelection : .clear)
.clipShape(RoundedRectangle(cornerRadius: 5))
.contentShape(Rectangle())
// Branch and upstream names are truncated to keep the row width
// fixed, so the untruncated pair is only reachable on hover.
.help(branchRowTooltip(reference))
},
menuContent: { branchActionMenu(for: reference) }
)
.disabled(model.isPerformingBranchOperation)
}

/// The full branch name, plus its upstream when tracked, for rows whose text
/// the fixed popup width truncates.
private func branchRowTooltip(_ reference: GitReference) -> String {
guard let upstream = reference.upstreamShortName else { return reference.shortName }
return "\(reference.shortName) → \(upstream)"
}

/// The per-reference action list, ordered like IDEA's branch menu: creation
/// and comparison first, then checkout and integration, then destructive
/// entries last.
@ViewBuilder
private func branchActionMenu(for reference: GitReference) -> some View {
Button("New Branch from '\(reference.shortName)'…") {
dismissAndRun { onNewBranch(reference) }
}

Button("Show Diff with Working Tree") {
dismissAndRun { Task { await model.showComparisonWithWorkingTree(for: reference) } }
}

if let current = model.currentGitReference, current.id != reference.id {
Button("Compare with Current Branch") {
dismissAndRun { Task { await model.showComparison(from: reference, to: current) } }
}
}

if !reference.isCurrent {
Divider()

Button("Checkout") {
dismissAndRun { Task { await model.checkoutReference(reference) } }
}
}

if reference.kind == .local {
Divider()

Button("Update") {
dismissAndRun { Task { await model.updateCurrentBranch(reference) } }
}
.disabled(!reference.isCurrent)

Button("Push…") {
dismissAndRun { onPush(reference) }
}
}

if reference.kind == .local, !reference.isCurrent {
Divider()

Button("Delete", role: .destructive) {
dismissAndRun { onDelete(reference) }
}
.padding(.leading, branchRowLeadingPadding(indented: indented, presentation: presentation))
.padding(.trailing, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Metrics.branchRowHeight)
.background(highlightsCurrent ? LitheTheme.subtleSelection : .clear)
.clipShape(RoundedRectangle(cornerRadius: 5))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.lithePointer()
.disabled(model.isPerformingBranchOperation)
}

/// Closes the popover before running a branch action so the action's own
/// sheet or dialog is not presented behind a popover that is about to go away.
private func dismissAndRun(_ action: @escaping () -> Void) {
isPresented = false
action()
}

private var recentReferences: [GitReference] {
Expand Down Expand Up @@ -577,6 +642,37 @@ struct BranchSwitcherPopover: View {
}
}

/// A branch row that surfaces its actions through a native pop-up menu rather
/// than a direct checkout.
///
/// Using `SwiftUI.Menu` with `.menuStyle(.borderlessButton)` produces a native
/// NSMenu, which works correctly inside the outer popover, positions itself to
/// avoid screen edges, and provides the hover-safety path that IDEA exposes:
/// once any row's menu is open, moving the cursor to another row opens that
/// menu immediately without a click.
private struct BranchActionMenuRow<Label: View, MenuContent: View>: View {
@ViewBuilder let label: () -> Label
@ViewBuilder let menuContent: () -> MenuContent

@State private var isHovering = false

var body: some View {
SwiftUI.Menu {
menuContent()
} label: {
label()
.background(isHovering ? LitheTheme.subtleSelection : .clear)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
// Constrain to the list width so the menu button does not stretch.
.fixedSize(horizontal: false, vertical: true)
.lithePointer()
.onHover { isHovering = $0 }
}
}

private enum BranchRowPresentation {
case recent
case grouped
Expand Down
30 changes: 30 additions & 0 deletions macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ struct WorkbenchView: View {
@State private var newBranchReference: GitReference?
@State private var isCheckoutRevisionPresented = false
@State private var pendingTopBarPushReference: GitReference?
@State private var pendingTopBarDeleteReference: GitReference?
@State private var isProjectSwitcherPresented = false
@State private var isPluginPanelPresented = false
@State private var isNotificationCenterPresented = false
Expand Down Expand Up @@ -278,6 +279,31 @@ struct WorkbenchView: View {
}
)
}
.confirmationDialog(
"Delete branch?",
isPresented: Binding(
get: { pendingTopBarDeleteReference != nil },
set: { if !$0 { pendingTopBarDeleteReference = nil } }
),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
guard let reference = pendingTopBarDeleteReference else { return }
pendingTopBarDeleteReference = nil
Task { await model.deleteBranch(reference) }
}
.disabled(model.isPerformingBranchOperation)
.lithePointer()
Button("Cancel", role: .cancel) {
pendingTopBarDeleteReference = nil
}
.lithePointer()
} message: {
Text(
"Delete the local branch \(pendingTopBarDeleteReference?.shortName ?? "")? "
+ "Git will refuse if it contains unmerged work."
)
}
.overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in
GeometryReader { geometry in
if isProjectSwitcherPresented, let bounds {
Expand Down Expand Up @@ -678,6 +704,10 @@ struct WorkbenchView: View {
updateSwitcherPresentation(branch: false)
pendingTopBarPushReference = reference
},
onDelete: { reference in
updateSwitcherPresentation(branch: false)
pendingTopBarDeleteReference = reference
},
onNewBranch: { reference in
updateSwitcherPresentation(branch: false)
newBranchReference = reference
Expand Down
Loading
Loading