6218a6ef28
- NSTextView-based syntax highlighting with regex tokenizer - Swift, C/C++, ObjC, JSON keywords, types, strings, comments - Theme-aware coloring, debounced re-highlighting - Workspace-wide search across all source files - Grouped results by file with line numbers - Scope toggle: current file vs all files - Git status badges on file tree nodes - GitService changes flow to FileNode.gitStatus - Agent file operations refresh file tree - Auto-restore last workspace on launch - All tests passing (0 errors, 0 warnings)
896 lines
35 KiB
Swift
896 lines
35 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - IDE Container View (VSCode Layout)
|
|
|
|
struct IDEContainerView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
@State private var sidebarWidth: CGFloat = 260
|
|
@State private var panelHeight: CGFloat = 200
|
|
@State private var chatPanelWidth: CGFloat = 360
|
|
@State private var sidebarDragStart: CGFloat?
|
|
@State private var panelDragStart: CGFloat?
|
|
@State private var chatDragStart: CGFloat?
|
|
@State private var isDraggingSidebar = false
|
|
@State private var isDraggingPanel = false
|
|
@State private var isDraggingChat = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if viewModel.hasWorkspace {
|
|
mainLayout
|
|
.overlay { commandPaletteOverlay }
|
|
.overlay(alignment: .topTrailing) { notificationStack }
|
|
} else {
|
|
WelcomeView(workspaceService: viewModel.workspaceService, viewModel: viewModel)
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.frame(minWidth: 900, minHeight: 600)
|
|
}
|
|
|
|
// MARK: - Main Layout
|
|
|
|
private var mainLayout: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
activityBar
|
|
|
|
if viewModel.showSidebar {
|
|
sidebarPanel
|
|
.frame(width: sidebarWidth)
|
|
|
|
Rectangle()
|
|
.fill(isDraggingSidebar ? VSC.focusBorder : VSC.border)
|
|
.frame(width: 1)
|
|
.contentShape(Rectangle().inset(by: -3))
|
|
.gesture(
|
|
DragGesture(minimumDistance: 1)
|
|
.onChanged { value in
|
|
if sidebarDragStart == nil { sidebarDragStart = sidebarWidth }
|
|
isDraggingSidebar = true
|
|
sidebarWidth = max(180, min(500, (sidebarDragStart ?? 260) + value.translation.width))
|
|
}
|
|
.onEnded { _ in
|
|
isDraggingSidebar = false
|
|
sidebarDragStart = nil
|
|
}
|
|
)
|
|
.onHover { inside in
|
|
if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() }
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 0) {
|
|
CodeEditorView(viewModel: viewModel)
|
|
|
|
if viewModel.showPanel {
|
|
Rectangle()
|
|
.fill(isDraggingPanel ? VSC.focusBorder : VSC.border)
|
|
.frame(height: 1)
|
|
.contentShape(Rectangle().inset(by: -3))
|
|
.gesture(
|
|
DragGesture(minimumDistance: 1)
|
|
.onChanged { value in
|
|
if panelDragStart == nil { panelDragStart = panelHeight }
|
|
isDraggingPanel = true
|
|
panelHeight = max(100, min(500, (panelDragStart ?? 200) - value.translation.height))
|
|
}
|
|
.onEnded { _ in
|
|
isDraggingPanel = false
|
|
panelDragStart = nil
|
|
}
|
|
)
|
|
.onHover { inside in
|
|
if inside { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
|
}
|
|
|
|
ConsoleView(viewModel: viewModel)
|
|
.frame(height: panelHeight)
|
|
}
|
|
}
|
|
|
|
// MARK: - Right-Side Chat Panel
|
|
if viewModel.showChatPanel {
|
|
Rectangle()
|
|
.fill(isDraggingChat ? VSC.focusBorder : VSC.border)
|
|
.frame(width: 1)
|
|
.contentShape(Rectangle().inset(by: -3))
|
|
.gesture(
|
|
DragGesture(minimumDistance: 1)
|
|
.onChanged { value in
|
|
if chatDragStart == nil { chatDragStart = chatPanelWidth }
|
|
isDraggingChat = true
|
|
chatPanelWidth = max(280, min(600, (chatDragStart ?? 360) - value.translation.width))
|
|
}
|
|
.onEnded { _ in
|
|
isDraggingChat = false
|
|
chatDragStart = nil
|
|
}
|
|
)
|
|
.onHover { inside in
|
|
if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() }
|
|
}
|
|
|
|
ChatPanelView(viewModel: viewModel)
|
|
.frame(width: chatPanelWidth)
|
|
}
|
|
}
|
|
|
|
statusBar
|
|
}
|
|
.background(VSC.editorBg)
|
|
}
|
|
|
|
// MARK: - Activity Bar
|
|
|
|
private var activityBar: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 2) {
|
|
activityBarButton(.explorer)
|
|
activityBarButton(.search)
|
|
activityBarButton(.git)
|
|
activityBarButton(.debug)
|
|
activityBarButton(.agent)
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
Spacer()
|
|
|
|
VStack(spacing: 2) {
|
|
activityBarButton(.snippets)
|
|
activityBarButton(.settings)
|
|
}
|
|
.padding(.bottom, 8)
|
|
}
|
|
.frame(width: 48)
|
|
.background(VSC.activityBarBg)
|
|
}
|
|
|
|
private func activityBarButton(_ panel: ActivityPanel) -> some View {
|
|
let isActive = viewModel.activePanel == panel && viewModel.showSidebar
|
|
|
|
return Button(action: {
|
|
if viewModel.activePanel == panel {
|
|
viewModel.showSidebar.toggle()
|
|
} else {
|
|
viewModel.activePanel = panel
|
|
viewModel.showSidebar = true
|
|
}
|
|
}) {
|
|
ZStack {
|
|
Image(systemName: panel.iconName)
|
|
.font(.system(size: 22))
|
|
.foregroundColor(isActive ? VSC.activityActive : VSC.activityInactive)
|
|
.frame(width: 48, height: 48)
|
|
|
|
if isActive {
|
|
HStack {
|
|
Rectangle()
|
|
.fill(Color.white)
|
|
.frame(width: 2, height: 24)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
if panel == .git, gitBadgeCount > 0 {
|
|
badgeOverlay(gitBadgeCount)
|
|
}
|
|
if panel == .debug, viewModel.diagnostics.count > 0 {
|
|
badgeOverlay(viewModel.diagnostics.count)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var gitBadgeCount: Int {
|
|
let s = viewModel.gitService.status
|
|
return s.modified + s.untracked + s.staged + s.conflicted
|
|
}
|
|
|
|
private func badgeOverlay(_ count: Int) -> some View {
|
|
VStack {
|
|
HStack {
|
|
Spacer()
|
|
Text("\(min(count, 99))")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
.background(VSC.badgeBg)
|
|
.cornerRadius(8)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.top, 4)
|
|
.padding(.trailing, 4)
|
|
}
|
|
|
|
// MARK: - Sidebar Panel
|
|
|
|
private var sidebarPanel: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(viewModel.activePanel.rawValue.uppercased())
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(VSC.dimText)
|
|
.tracking(0.8)
|
|
Spacer()
|
|
|
|
if viewModel.activePanel == .explorer {
|
|
HStack(spacing: 4) {
|
|
sidebarHeaderButton("doc.badge.plus", help: "New File")
|
|
sidebarHeaderButton("folder.badge.plus", help: "New Folder")
|
|
sidebarHeaderButton("arrow.clockwise", help: "Refresh") {
|
|
viewModel.workspaceService.refreshFileTree()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
|
|
switch viewModel.activePanel {
|
|
case .explorer:
|
|
FileTreeView(workspaceService: viewModel.workspaceService, viewModel: viewModel)
|
|
case .search:
|
|
SearchSidebarView(viewModel: viewModel)
|
|
case .git:
|
|
GitSidebarView(gitService: viewModel.gitService, viewModel: viewModel)
|
|
case .snippets:
|
|
SnippetsSidebarView(viewModel: viewModel)
|
|
case .debug:
|
|
DebugSidebarView(viewModel: viewModel)
|
|
case .agent:
|
|
AgentSidebarView(viewModel: viewModel)
|
|
case .chat:
|
|
// Chat is on the right-side panel, not in sidebar
|
|
EmptyView()
|
|
case .settings:
|
|
SettingsSidebarView(viewModel: viewModel)
|
|
}
|
|
}
|
|
.background(VSC.sidebarBg)
|
|
}
|
|
|
|
private func sidebarHeaderButton(_ icon: String, help: String, action: @escaping () -> Void = {}) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(help)
|
|
}
|
|
|
|
// MARK: - Status Bar
|
|
|
|
private var statusBar: some View {
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 12) {
|
|
if viewModel.gitService.isGitRepo {
|
|
statusBarItem {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
.font(.system(size: 10))
|
|
Text(viewModel.gitService.status.branch)
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
}
|
|
|
|
statusBarItem {
|
|
HStack(spacing: 8) {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "xmark.circle")
|
|
.font(.system(size: 10))
|
|
Text("\(viewModel.errorCount)")
|
|
.font(.system(size: 11))
|
|
}
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 10))
|
|
Text("\(viewModel.warningCount)")
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, 8)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 0) {
|
|
if let tab = viewModel.activeTab {
|
|
statusBarItem {
|
|
Text("Ln \(tab.cursorLine), Col \(tab.cursorColumn)")
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
statusBarItem {
|
|
Text("Spaces: 4").font(.system(size: 11))
|
|
}
|
|
statusBarItem {
|
|
Text("UTF-8").font(.system(size: 11))
|
|
}
|
|
if let tab = viewModel.activeTab {
|
|
statusBarItem {
|
|
Text(tab.language.displayName).font(.system(size: 11))
|
|
}
|
|
}
|
|
}
|
|
.padding(.trailing, 8)
|
|
}
|
|
.frame(height: 22)
|
|
.background(viewModel.hasWorkspace ? VSC.statusBarBg : VSC.statusBarNoFolder)
|
|
.foregroundColor(.white.opacity(0.9))
|
|
}
|
|
|
|
private func statusBarItem<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
content()
|
|
.padding(.horizontal, 6)
|
|
.frame(height: 22)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Command Palette
|
|
|
|
@ViewBuilder
|
|
private var commandPaletteOverlay: some View {
|
|
if viewModel.showCommandPalette {
|
|
ZStack {
|
|
Color.black.opacity(0.25)
|
|
.ignoresSafeArea()
|
|
.onTapGesture {
|
|
viewModel.showCommandPalette = false
|
|
viewModel.commandSearch = ""
|
|
}
|
|
|
|
VStack {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(VSC.dimText)
|
|
TextField("Type a command...", text: $viewModel.commandSearch)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white)
|
|
.onSubmit {
|
|
if let first = viewModel.filteredCommands.first {
|
|
first.action()
|
|
viewModel.showCommandPalette = false
|
|
viewModel.commandSearch = ""
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
|
|
Divider().background(VSC.border)
|
|
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(viewModel.filteredCommands) { cmd in
|
|
Button {
|
|
cmd.action()
|
|
viewModel.showCommandPalette = false
|
|
viewModel.commandSearch = ""
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: cmd.icon)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(VSC.dimText)
|
|
.frame(width: 20)
|
|
Text(cmd.title)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(VSC.text)
|
|
Spacer()
|
|
if let shortcut = cmd.shortcut {
|
|
Text(shortcut)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: 300)
|
|
}
|
|
.frame(width: 500)
|
|
.background(VSC.sidebarBg)
|
|
.cornerRadius(6)
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(VSC.border, lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.5), radius: 16, y: 8)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 60)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
@ViewBuilder
|
|
private var notificationStack: some View {
|
|
VStack(spacing: 8) {
|
|
ForEach(viewModel.notifications) { notification in
|
|
NotificationBanner(notification: notification) {
|
|
viewModel.dismissNotification(notification)
|
|
}
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
}
|
|
.padding(.top, 40)
|
|
.padding(.trailing, 16)
|
|
.animation(.spring(response: 0.3), value: viewModel.notifications.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Sidebar
|
|
|
|
struct SearchSidebarView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
TextField("Search", text: $viewModel.searchState.query)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white)
|
|
.onSubmit { runSearch() }
|
|
}
|
|
.padding(6)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VSC.inputBorder, lineWidth: 1))
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.2.squarepath")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
TextField("Replace", text: $viewModel.searchState.replacement)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white)
|
|
}
|
|
.padding(6)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VSC.inputBorder, lineWidth: 1))
|
|
|
|
HStack(spacing: 8) {
|
|
searchToggle("Aa", isOn: $viewModel.searchState.isCaseSensitive, help: "Match Case")
|
|
searchToggle(".*", isOn: $viewModel.searchState.isRegex, help: "Use Regex")
|
|
searchToggle("W", isOn: $viewModel.searchState.isWholeWord, help: "Whole Word")
|
|
|
|
Spacer()
|
|
|
|
// Scope picker
|
|
Picker("", selection: $viewModel.searchState.scope) {
|
|
ForEach(SearchState.SearchScope.allCases, id: \.self) { scope in
|
|
Text(scope.rawValue).tag(scope)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 160)
|
|
.controlSize(.mini)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
|
|
Divider().background(VSC.border)
|
|
|
|
// Results
|
|
if viewModel.isSearchingWorkspace {
|
|
HStack(spacing: 8) {
|
|
ProgressView().scaleEffect(0.6)
|
|
Text("Searching workspace...")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(VSC.dimText)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
if viewModel.searchState.scope == .allFiles {
|
|
workspaceResults
|
|
} else {
|
|
currentFileResults
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentFileResults: some View {
|
|
VStack(spacing: 0) {
|
|
if viewModel.searchState.matchCount > 0 {
|
|
HStack(spacing: 8) {
|
|
Text("\(viewModel.searchState.matchCount) results in current file")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(VSC.dimText)
|
|
Spacer()
|
|
Button("Replace Next") { viewModel.replaceNext() }
|
|
.font(.system(size: 10))
|
|
.controlSize(.mini)
|
|
Button("Replace All") { viewModel.replaceAll() }
|
|
.font(.system(size: 10))
|
|
.controlSize(.mini)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
} else if !viewModel.searchState.query.isEmpty {
|
|
Text("No results found.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
.padding(12)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var workspaceResults: some View {
|
|
Group {
|
|
if !viewModel.workspaceSearchResults.isEmpty {
|
|
HStack {
|
|
Text("\(viewModel.workspaceSearchResults.count) results")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(VSC.dimText)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
let grouped = Dictionary(grouping: viewModel.workspaceSearchResults) { $0.fileURL }
|
|
ForEach(grouped.keys.sorted(by: { $0.path < $1.path }), id: \.self) { fileURL in
|
|
searchFileGroup(fileURL: fileURL, results: grouped[fileURL] ?? [])
|
|
}
|
|
}
|
|
}
|
|
} else if !viewModel.searchState.query.isEmpty && !viewModel.isSearchingWorkspace {
|
|
Text("No results found in workspace.")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
.padding(12)
|
|
Spacer()
|
|
} else {
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func searchFileGroup(fileURL: URL, results: [WorkspaceSearchResult]) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "doc.text")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(VSC.dimText)
|
|
Text(fileURL.lastPathComponent)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(VSC.text)
|
|
Text("\(results.count)")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(VSC.dimText)
|
|
.padding(.horizontal, 4)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
.background(VSC.inputBg.opacity(0.5))
|
|
|
|
ForEach(results) { result in
|
|
Button(action: { viewModel.openFile(at: result.fileURL) }) {
|
|
HStack(spacing: 6) {
|
|
Text("\(result.line)")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundColor(VSC.dimText)
|
|
.frame(width: 28, alignment: .trailing)
|
|
Text(result.lineContent.trimmingCharacters(in: .whitespaces))
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(VSC.text)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 2)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func runSearch() {
|
|
if viewModel.searchState.scope == .allFiles {
|
|
viewModel.searchWorkspace()
|
|
} else {
|
|
viewModel.performSearch()
|
|
}
|
|
}
|
|
|
|
private func searchToggle(_ label: String, isOn: Binding<Bool>, help: String) -> some View {
|
|
Button(action: { isOn.wrappedValue.toggle(); runSearch() }) {
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
.foregroundColor(isOn.wrappedValue ? .white : VSC.dimText)
|
|
.frame(width: 22, height: 18)
|
|
.background(isOn.wrappedValue ? VSC.accentBlue.opacity(0.5) : Color.clear)
|
|
.cornerRadius(3)
|
|
.overlay(RoundedRectangle(cornerRadius: 3).stroke(VSC.inputBorder, lineWidth: 1))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(help)
|
|
}
|
|
}
|
|
|
|
// MARK: - Snippets Sidebar
|
|
|
|
struct SnippetsSidebarView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
private var categories: [String] {
|
|
Array(Set(CodeSnippet.builtIn.map(\.category))).sorted()
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(categories, id: \.self) { category in
|
|
DisclosureGroup {
|
|
ForEach(CodeSnippet.builtIn.filter { $0.category == category }) { snippet in
|
|
Button(action: { viewModel.insertSnippet(snippet) }) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "text.insert")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.orange)
|
|
.frame(width: 14)
|
|
Text(snippet.title)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.text)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 3)
|
|
.padding(.leading, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
} label: {
|
|
Text(category.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(VSC.dimText)
|
|
.tracking(0.5)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Sidebar
|
|
|
|
struct DebugSidebarView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sectionHeader("BREAKPOINTS", count: viewModel.breakpoints.count)
|
|
|
|
if viewModel.breakpoints.isEmpty {
|
|
emptyMessage("No breakpoints set")
|
|
} else {
|
|
ForEach(viewModel.breakpoints) { bp in
|
|
HStack(spacing: 6) {
|
|
Image(systemName: bp.isEnabled ? "circle.fill" : "circle")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.red)
|
|
Text(bp.file)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.text)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Text(":\(bp.line)")
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 3)
|
|
}
|
|
}
|
|
|
|
Divider().background(VSC.border).padding(.vertical, 8)
|
|
|
|
sectionHeader("C++ ANALYSIS", count: nil)
|
|
|
|
VStack(spacing: 6) {
|
|
analysisButton("Run Analysis", icon: "play.circle", action: viewModel.runCppAnalysis)
|
|
analysisButton("Checksum", icon: "number", action: viewModel.runChecksum)
|
|
analysisButton("Complexity", icon: "chart.bar", action: viewModel.runComplexityCheck)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 4)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sectionHeader(_ title: String, count: Int?) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(VSC.dimText)
|
|
.tracking(0.5)
|
|
if let count = count, count > 0 {
|
|
Text("\(count)")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
.background(VSC.badgeBg)
|
|
.cornerRadius(6)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func emptyMessage(_ text: String) -> some View {
|
|
Text(text)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func analysisButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon).font(.system(size: 11)).frame(width: 16)
|
|
Text(title).font(.system(size: 11))
|
|
Spacer()
|
|
}
|
|
.foregroundColor(VSC.text)
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, 8)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings Sidebar
|
|
|
|
struct SettingsSidebarView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
settingsGroup("Theme") {
|
|
ForEach(IDETheme.allThemes) { theme in
|
|
Button(action: { viewModel.currentTheme = theme }) {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(theme.editorBackground)
|
|
.frame(width: 14, height: 14)
|
|
.overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1))
|
|
Text(theme.name)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.text)
|
|
Spacer()
|
|
if viewModel.currentTheme == theme {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(VSC.accentBlue)
|
|
}
|
|
}
|
|
.padding(.vertical, 3)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
settingsGroup("Editor") {
|
|
HStack {
|
|
Text("Font Size").font(.system(size: 11)).foregroundColor(VSC.text)
|
|
Spacer()
|
|
HStack(spacing: 8) {
|
|
Button(action: viewModel.decreaseFontSize) {
|
|
Image(systemName: "minus").font(.system(size: 10))
|
|
}.buttonStyle(.plain)
|
|
Text("\(Int(viewModel.fontSize))")
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(VSC.text)
|
|
.frame(width: 24)
|
|
Button(action: viewModel.increaseFontSize) {
|
|
Image(systemName: "plus").font(.system(size: 10))
|
|
}.buttonStyle(.plain)
|
|
}
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
}
|
|
|
|
if let workspace = viewModel.workspaceService.currentWorkspace {
|
|
settingsGroup("Workspace") {
|
|
infoRow("Name", value: workspace.name)
|
|
infoRow("Path", value: workspace.rootURL.path)
|
|
infoRow("Files", value: "\(viewModel.workspaceService.fileTree.count) items")
|
|
}
|
|
}
|
|
|
|
settingsGroup("Shortcuts") {
|
|
shortcutRow("Command Palette", shortcut: "\u{21e7}\u{2318}P")
|
|
shortcutRow("Find", shortcut: "\u{2318}F")
|
|
shortcutRow("New Tab", shortcut: "\u{2318}N")
|
|
shortcutRow("Save", shortcut: "\u{2318}S")
|
|
shortcutRow("Run", shortcut: "\u{2318}R")
|
|
shortcutRow("Toggle Sidebar", shortcut: "\u{2318}B")
|
|
shortcutRow("Toggle Panel", shortcut: "\u{2318}J")
|
|
}
|
|
|
|
settingsGroup("About") {
|
|
infoRow("Version", value: "1.0.0")
|
|
infoRow("Engine", value: "Swift + C++ Hybrid")
|
|
infoRow("Agent", value: "MCP \(viewModel.agentService.toolCount) tools")
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private func settingsGroup<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title.uppercased())
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(VSC.dimText)
|
|
.tracking(0.5)
|
|
content()
|
|
}
|
|
}
|
|
|
|
private func infoRow(_ label: String, value: String) -> some View {
|
|
HStack {
|
|
Text(label).font(.system(size: 11)).foregroundColor(VSC.dimText)
|
|
Spacer()
|
|
Text(value).font(.system(size: 11)).foregroundColor(VSC.text).lineLimit(1).truncationMode(.middle)
|
|
}
|
|
}
|
|
|
|
private func shortcutRow(_ title: String, shortcut: String) -> some View {
|
|
HStack {
|
|
Text(title).font(.system(size: 11)).foregroundColor(VSC.text)
|
|
Spacer()
|
|
Text(shortcut)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(VSC.dimText)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(3)
|
|
}
|
|
}
|
|
}
|