2026-04-12 00:46:30 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct EditorTabBar: View {
|
|
|
|
|
@ObservedObject var editorState: EditorState
|
2026-04-12 22:07:35 -07:00
|
|
|
@Environment(\.requestTabClose) private var requestTabClose
|
2026-04-12 00:46:30 -07:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
|
|
|
HStack(spacing: 0) {
|
|
|
|
|
ForEach(editorState.tabs) { tab in
|
|
|
|
|
EditorTabCell(
|
|
|
|
|
tab: tab,
|
|
|
|
|
isActive: tab.id == editorState.activeTabId,
|
|
|
|
|
onSelect: { editorState.activeTabId = tab.id },
|
2026-04-12 22:07:35 -07:00
|
|
|
// Route through requestTabClose so dirty tabs show the
|
|
|
|
|
// unsaved changes alert instead of silently discarding.
|
|
|
|
|
onClose: { requestTabClose(tab.id) }
|
2026-04-12 00:46:30 -07:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(height: 36)
|
|
|
|
|
.background(Color(.secondarySystemBackground))
|
|
|
|
|
.overlay(alignment: .bottom) { Divider() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct EditorTabCell: View {
|
|
|
|
|
let tab: EditorTab
|
|
|
|
|
let isActive: Bool
|
|
|
|
|
let onSelect: () -> Void
|
|
|
|
|
let onClose: () -> Void
|
|
|
|
|
@State private var isHovered = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
if tab.isDirty {
|
|
|
|
|
Circle().fill(Color.accentColor).frame(width: 6, height: 6)
|
|
|
|
|
} else {
|
|
|
|
|
Button(action: onClose) {
|
|
|
|
|
Image(systemName: "xmark").font(.system(size: 9, weight: .semibold))
|
|
|
|
|
.frame(width: 14, height: 14).contentShape(Circle())
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.opacity((isActive || isHovered) ? 1 : 0)
|
|
|
|
|
}
|
|
|
|
|
Image(systemName: fileIcon(for: tab.fileName))
|
|
|
|
|
.font(.system(size: 11)).foregroundStyle(iconColor(for: tab.fileName))
|
|
|
|
|
Text(tab.fileName)
|
|
|
|
|
.font(.system(.caption, design: .monospaced)).lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 10).frame(height: 36)
|
|
|
|
|
.background(isActive ? Color(.systemBackground) : .clear)
|
|
|
|
|
.overlay(alignment: .bottom) {
|
|
|
|
|
if isActive { Rectangle().fill(Color.accentColor).frame(height: 2) }
|
|
|
|
|
}
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
.onTapGesture(perform: onSelect)
|
|
|
|
|
.onHover { isHovered = $0 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func fileIcon(for name: String) -> String {
|
|
|
|
|
switch (name as NSString).pathExtension.lowercased() {
|
|
|
|
|
case "swift": return "swift"; case "json": return "curlybraces"
|
|
|
|
|
case "md": return "doc.text"; case "sh": return "terminal"; default: return "doc"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private func iconColor(for name: String) -> Color {
|
|
|
|
|
switch (name as NSString).pathExtension.lowercased() {
|
|
|
|
|
case "swift": return .orange; case "json": return .yellow
|
|
|
|
|
case "md": return .blue; default: return .secondary
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|