Header button (shown only when locks are held) force-releases every holding session's locks at once, behind a confirmation, so all queued agents proceed. Each lock re-arms once its session merges or edits again. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
261 lines
10 KiB
Swift
261 lines
10 KiB
Swift
import SwiftUI
|
|
import NucleicCore
|
|
|
|
/// Cross-session file-lock queue: which sessions hold a lock on each file (their
|
|
/// unmerged changes are the implicit lock) and which sessions are queued behind them
|
|
/// via "Wait for Access". Refreshes live while open — the holder footprints come from
|
|
/// git (unmerged files), so this polls rather than binding to an observable.
|
|
struct LockQueueView: View {
|
|
@Environment(AppStore.self) private var store
|
|
@Environment(\.appPalette) private var palette
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var snapshot = LockQueueSnapshot(files: [], waiters: [])
|
|
@State private var loaded = false
|
|
@State private var confirmingReleaseAll = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
Divider()
|
|
content
|
|
Divider()
|
|
HStack {
|
|
Text(footerSummary).font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
|
|
}
|
|
.padding(16)
|
|
}
|
|
.frame(width: 560, height: 560)
|
|
.task { await poll() }
|
|
.confirmationDialog(
|
|
"Release all file locks?",
|
|
isPresented: $confirmingReleaseAll, titleVisibility: .visible
|
|
) {
|
|
Button("Release All Locks", role: .destructive) { releaseAll() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Clears every session's hold on its changed files so all queued agents "
|
|
+ "can proceed. Each lock re-arms once that session merges or edits again.")
|
|
}
|
|
}
|
|
|
|
/// Sessions currently holding at least one file lock.
|
|
private var holderIDs: [SessionID] {
|
|
var seen = Set<SessionID>()
|
|
return snapshot.files
|
|
.flatMap { $0.holders.map(\.sessionID) }
|
|
.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
// Poll the snapshot while the sheet is open. Cancels automatically on dismiss.
|
|
private func poll() async {
|
|
while !Task.isCancelled {
|
|
snapshot = await store.lockQueueSnapshot()
|
|
loaded = true
|
|
try? await Task.sleep(for: .seconds(2))
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "lock.doc")
|
|
.font(.title2).foregroundStyle(palette.accent)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("File Locks").font(.title2).bold()
|
|
Text("Which sessions hold each file and which are queued behind them.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if !holderIDs.isEmpty {
|
|
Button("Release All", systemImage: "lock.open") {
|
|
confirmingReleaseAll = true
|
|
}
|
|
.help("Force-release every session's file locks so all queued agents can proceed")
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if !loaded {
|
|
centered { ProgressView() }
|
|
} else if snapshot.isEmpty {
|
|
centered {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "lock.open")
|
|
.font(.largeTitle).foregroundStyle(.secondary)
|
|
Text("No active file locks")
|
|
.font(.headline)
|
|
Text("A session holds a lock on the files it has changed but not yet "
|
|
+ "merged. Locks clear automatically once the work merges.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: 360)
|
|
}
|
|
}
|
|
} else {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if !snapshot.files.isEmpty {
|
|
section("Locked files") {
|
|
ForEach(snapshot.files) { lock in
|
|
FileLockCard(lock: lock, onOpen: open, onRelease: release)
|
|
}
|
|
}
|
|
}
|
|
// Waiters with no predicted files won't appear under any file above;
|
|
// surface every queued session here so none is hidden.
|
|
let unfiled = snapshot.waiters.filter { $0.wantedFiles.isEmpty }
|
|
if !unfiled.isEmpty {
|
|
section("Queued (no specific file)") {
|
|
ForEach(unfiled) { waiter in
|
|
WaiterCard(waiter: waiter, onOpen: open)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func section(_ title: String, @ViewBuilder _ body: () -> some View) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(title.uppercased())
|
|
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
|
body()
|
|
}
|
|
}
|
|
|
|
private func centered(@ViewBuilder _ body: () -> some View) -> some View {
|
|
VStack { Spacer(); body(); Spacer() }
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(16)
|
|
}
|
|
|
|
private var footerSummary: String {
|
|
let holders = Set(snapshot.files.flatMap { $0.holders.map(\.sessionID) }).count
|
|
let waiters = snapshot.waiters.count
|
|
if holders == 0 && waiters == 0 { return "No locks held" }
|
|
let h = "\(holders) session\(holders == 1 ? "" : "s") holding \(snapshot.files.count) file\(snapshot.files.count == 1 ? "" : "s")"
|
|
return waiters == 0 ? h : "\(h) · \(waiters) waiting"
|
|
}
|
|
|
|
private func open(_ sessionID: SessionID) {
|
|
store.openSessionID = sessionID
|
|
dismiss()
|
|
}
|
|
|
|
private func release(_ sessionID: SessionID) {
|
|
Task {
|
|
await store.forceReleaseLocks(sessionID)
|
|
snapshot = await store.lockQueueSnapshot()
|
|
}
|
|
}
|
|
|
|
private func releaseAll() {
|
|
let ids = holderIDs
|
|
Task {
|
|
for id in ids { await store.forceReleaseLocks(id) }
|
|
snapshot = await store.lockQueueSnapshot()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One locked file: the path, its holder(s) (green lock), and queued waiter(s) (amber).
|
|
private struct FileLockCard: View {
|
|
@Environment(\.appPalette) private var palette
|
|
let lock: FileLock
|
|
let onOpen: (SessionID) -> Void
|
|
let onRelease: (SessionID) -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(lock.path)
|
|
.font(.callout.monospaced()).bold()
|
|
.lineLimit(1).truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
ForEach(lock.holders) { holder in
|
|
participantRow(
|
|
holder, icon: "lock.fill", tint: palette.success, role: "Holding",
|
|
trailing: {
|
|
Button("Release") { onRelease(holder.sessionID) }
|
|
.buttonStyle(.borderless).font(.caption)
|
|
.help("Force-release this session's lock so queued agents can proceed. "
|
|
+ "It re-arms once the session merges or edits again.")
|
|
})
|
|
}
|
|
|
|
if !lock.waiters.isEmpty {
|
|
Divider()
|
|
ForEach(lock.waiters) { waiter in
|
|
participantRow(
|
|
waiter, icon: "hourglass", tint: palette.attention, role: "Waiting",
|
|
trailing: { EmptyView() })
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(AppTheme.surface, in: .rect(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func participantRow(
|
|
_ p: LockParticipant, icon: String, tint: Color, role: String,
|
|
@ViewBuilder trailing: () -> some View
|
|
) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: icon).foregroundStyle(tint).frame(width: 16)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 6) {
|
|
Text(role).font(.caption2.weight(.semibold)).foregroundStyle(tint)
|
|
Button(p.title) { onOpen(p.sessionID) }
|
|
.buttonStyle(.plain).font(.callout).lineLimit(1)
|
|
.help("Open “\(p.title)”")
|
|
}
|
|
if !p.task.isEmpty {
|
|
Text(p.task).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
}
|
|
Spacer(minLength: 8)
|
|
trailing()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A queued session that predicted no specific file (it matched another agent's work
|
|
/// by intent). Shows what it's waiting on and what's blocking it.
|
|
private struct WaiterCard: View {
|
|
@Environment(\.appPalette) private var palette
|
|
let waiter: WaitingSession
|
|
let onOpen: (SessionID) -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "hourglass").foregroundStyle(palette.attention).frame(width: 16)
|
|
Button(waiter.title) { onOpen(waiter.sessionID) }
|
|
.buttonStyle(.plain).font(.callout).bold().lineLimit(1)
|
|
Spacer()
|
|
}
|
|
if !waiter.task.isEmpty {
|
|
Text(waiter.task).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
|
}
|
|
if !waiter.blockedBy.isEmpty {
|
|
Text("Blocked by " + waiter.blockedBy.map(\.title).joined(separator: ", "))
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppTheme.surface, in: .rect(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
|
}
|
|
}
|