import SwiftUI import Foundation struct ContentView: View { @EnvironmentObject var daemonConfig: DaemonConfiguration @EnvironmentObject var projectStore: ProjectStore @EnvironmentObject var editorState: EditorState @EnvironmentObject var gitStore: GitStore @EnvironmentObject var lspClient: LSPClient let gitService: GitService @StateObject private var buildService: BuildService @StateObject private var syncPipeline: FileSyncPipeline @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var leftPanel: LeftPanel = .files @State private var consoleHeight: CGFloat = 180 @State private var consoleVisible = true @State private var activeProject: SavedProject? @State private var selectedDevice: ConnectedDevice? @State private var showSettings = false @State private var showProjectPicker = false @State private var showFindInProject = false @State private var showNewFile = false @State private var showRename = false @State private var newFilePath = "" @State private var renamingNode: FileNode? @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false enum LeftPanel { case files, git } // BuildService and FileSyncPipeline are initialised lazily in onAppear // once the @EnvironmentObject instances are available — NOT in init() where // they haven't been injected yet. We use placeholder objects here and replace // them in initialLoad(). // // The real fix: move StateObject creation out of init entirely and drive it // from the environment. Since @StateObject requires init-time wrappedValue, // we pass the environment config via a separate path using @EnvironmentObject // indirection resolved in task{}. init(gitService: GitService) { self.gitService = gitService // Temporary placeholder config — replaced with the real environment // DaemonConfiguration in initialLoad() via buildService.updateConfig(). // This ensures BuildService always uses the IP/port the user configured. let placeholderConfig = DaemonConfiguration() let placeholderStore = GitStore() let bs = BuildService(config: placeholderConfig) _buildService = StateObject(wrappedValue: bs) _syncPipeline = StateObject(wrappedValue: FileSyncPipeline( buildService: bs, gitService: gitService, gitStore: placeholderStore)) } var body: some View { Group { if !hasCompletedOnboarding { OnboardingView() } else { mainIDE } } .toastOverlay() } // MARK: - Main IDE private var mainIDE: some View { NavigationSplitView(columnVisibility: $columnVisibility) { leftColumn } detail: { rightColumn } .unsavedChangesGuard(editorState: editorState, buildService: buildService) .task { await initialLoad() } .onChange(of: buildService.consoleLines) { _, lines in applyDiagnostics(from: lines) } .onChange(of: lspClient.diagnostics) { _, diags in for (path, ranges) in diags { editorState.applyDiagnostics(ranges, toPath: path) } } .onReceive(NotificationCenter.default.publisher(for: .gitCommitCompleted)) { _ in ToastStore.shared.show("Committed successfully", style: .git) refreshGit() } .onReceive(NotificationCenter.default.publisher(for: .gitPushCompleted)) { _ in ToastStore.shared.show("Pushed to remote", style: .success) } .onReceive(NotificationCenter.default.publisher(for: .gitCheckoutCompleted)) { _ in ToastStore.shared.show("Switched branch", style: .git); refreshGit() } .onReceive(NotificationCenter.default.publisher(for: .gitOperationFailed)) { note in let msg = note.userInfo?["message"] as? String ?? "Git operation failed" ToastStore.shared.show(msg, style: .error) } .onReceive(NotificationCenter.default.publisher(for: .consoleClearRequested)) { _ in buildService.consoleLines = [] } .sheet(isPresented: $showSettings) { SettingsView(editorState: editorState, projectStore: projectStore) } .sheet(isPresented: $showProjectPicker) { ProjectPickerSheet(projectStore: projectStore, activeProject: $activeProject) { project in Task { await loadProject(project) } } } .sheet(isPresented: $showFindInProject) { FindAcrossFilesView( fileTree: buildService.fileTree, buildService: buildService ) { path, range in Task { await openFileAtRange(path: path, range: range) } } } .sheet(isPresented: $showNewFile) { NewFileSheet(parentPath: newFilePath) { parent, name in Task { await createFile(parent: parent, name: name) } } } .sheet(isPresented: $showRename) { if let node = renamingNode { RenameSheet(node: node) { node, name in Task { await renameFile(node: node, newName: name) } } } } } // MARK: - Left Column private var leftColumn: some View { VStack(spacing: 0) { Picker("Panel", selection: $leftPanel) { Label("Files", systemImage: "folder").tag(LeftPanel.files) Label("Git", systemImage: "arrow.triangle.branch").tag(LeftPanel.git) } .pickerStyle(.segmented).padding(.horizontal,8).padding(.vertical,6) Divider() switch leftPanel { case .files: GitAwareFileNavigatorView( tree: buildService.fileTree, fileStatuses: gitStore.fileStatuses, onSelect: { node in guard !node.isDirectory else { return }; Task { await openFile(node) } }, onCreateFile: { path in newFilePath = path; showNewFile = true }, onCreateFolder: { path in Task { await createFile(parent: path, name: ".gitkeep") } }, onRename: { node in renamingNode = node; showRename = true }, onDelete: { node in Task { await deleteFile(node) } } ) case .git: GitPanel(gitStore: gitStore, gitService: gitService) } } .navigationTitle(leftPanel == .files ? (activeProject?.name ?? "Files") : "Source Control") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { showProjectPicker = true } label: { Image(systemName: "folder.badge.gearshape") } } ToolbarItemGroup(placement: .navigationBarTrailing) { Button { Task { if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } } label: { Image(systemName: "arrow.clockwise") } Button { showSettings = true } label: { Image(systemName: "gearshape") } } } } // MARK: - Right Column private var rightColumn: some View { VStack(spacing: 0) { BuildToolbar( scheme: activeProject?.scheme ?? "No Project", devices: buildService.devices, selectedDevice: $selectedDevice, isBuilding: buildService.isBuilding, buildService: buildService, onBuild: { triggerBuild("build") }, onRun: { triggerBuild("buildAndRun") }, onRefreshDevices: { Task { await buildService.fetchDevices() } }, onFindInProject: { showFindInProject = true } ) Divider() if !editorState.tabs.isEmpty { EditorTabBar(editorState: editorState) } if let tab = editorState.activeTab { CodeEditorView( filePath: tab.path, content: Binding( get: { tab.content }, set: { nc in editorState.updateContent(tabId: tab.id, newContent: nc) syncPipeline.scheduleSave(path: tab.path, content: nc) } ), diagnosticRanges: tab.diagnostics, lspClient: lspClient, onSave: { Task { await syncPipeline.saveNow(path: tab.path, content: tab.content) editorState.markSaved(tabId: tab.id) ToastStore.shared.show("Saved", style: .success, duration: 1.5) } }, onBuild: { triggerBuild("build") }, onRun: { triggerBuild("buildAndRun") }, onToggleNavigator: { withAnimation { columnVisibility = columnVisibility == .all ? .detailOnly : .all } }, onToggleConsole: { withAnimation { consoleVisible.toggle() } } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView("No File Open", systemImage: "doc.text", description: Text("Select a file from the navigator.")) .frame(maxWidth: .infinity, maxHeight: .infinity) } if consoleVisible { ConsoleResizeHandle(height: $consoleHeight); Divider() EnhancedConsoleView(lines: buildService.consoleLines, onClear: { buildService.clearConsole() }).frame(height: consoleHeight) } } } // MARK: - Actions private func initialLoad() async { gitStore.checkInstallation() // Rewire BuildService and LSPClient to use the real environment DaemonConfiguration // (the one the user edits in Settings), not the placeholder created in init(). buildService.rewireConfig(daemonConfig, gitStore: gitStore) syncPipeline.rewireGitStore(gitStore) lspClient.config = daemonConfig await buildService.fetchDevices() editorState.onSaveRequest = { [weak syncPipeline] path, content in await syncPipeline?.saveNow(path: path, content: content) } editorState.onTabClosed = { [weak lspClient, weak daemonConfig] path in guard let cfg = daemonConfig, cfg.lspEnabled else { return } lspClient?.didClose(filePath: path) } if let last = projectStore.projects.first { await loadProject(last) } } private func loadProject(_ project: SavedProject) async { activeProject = project let lspEnabled = daemonConfig.lspEnabled // capture on main actor before async let async let tree: Void = buildService.fetchFileTree(projectPath: project.rootPath) async let lsp: Void = { if lspEnabled { await lspClient.connect(projectPath: project.rootPath) } }() await tree; await lsp syncPipeline.registerProject(projectRoot: project.rootPath, repoName: project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName) if gitStore.isWorkingCopyInstalled { gitStore.activeRepo = project.workingCopyRepoName.isEmpty ? project.name : project.workingCopyRepoName gitService.fetchStatus(repo: gitStore.activeRepo) gitService.fetchBranches(repo: gitStore.activeRepo) } } private func openFile(_ node: FileNode) async { guard let content = await buildService.fetchFileContent(path: node.path) else { return } editorState.openFile(path: node.path, content: content) if daemonConfig.lspEnabled { lspClient.didOpen(filePath: node.path, content: content) } } private func openFileAtRange(path: String, range: NSRange) async { await openFile(FileNode(name: URL(fileURLWithPath: path).lastPathComponent, path: path, isDirectory: false, children: nil)) Task { try? await Task.sleep(for: .milliseconds(300)) NotificationCenter.default.post(name: .navigateToRange, object: nil, userInfo: ["range": range]) } } private func triggerBuild(_ action: String) { guard let project = activeProject else { ToastStore.shared.show("No project selected", style:.warning); return } editorState.tabs.forEach { editorState.clearDiagnostics(forPath: $0.path) } let bid = project.bundleIdentifier.isEmpty ? nil : project.bundleIdentifier Task { await buildService.startBuild( projectPath: project.rootPath, scheme: project.scheme, selectedDevice: selectedDevice, action: action, bundleIdentifier: bid) } } private func applyDiagnostics(from lines: [ConsoleLine]) { let parsed = BuildDiagnosticParser.parse(lines.map(\.text)) let grouped = Dictionary(grouping: parsed, by: \.filePath) for (path, diags) in grouped { editorState.applyDiagnostics(diags.map { $0.toDiagnosticRange() }, toPath: path) } } private func refreshGit() { guard !gitStore.activeRepo.isEmpty else { return } gitService.fetchStatus(repo: gitStore.activeRepo) gitService.fetchLog(repo: gitStore.activeRepo) } // MARK: - File Operations private func createFile(parent: String, name: String) async { guard let url = URL(string: "\(daemonConfig.baseURL)/files/create") else { return } let body = ["parentPath": parent, "fileName": name, "content": ""] guard let data = try? JSONEncoder().encode(body) else { return } var req = URLRequest(url: url); req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data if let (_, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 201 { ToastStore.shared.show("Created \(name)", style: .success) if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } } private func renameFile(node: FileNode, newName: String) async { guard let url = URL(string: "\(daemonConfig.baseURL)/files/rename") else { return } let body = ["path": node.path, "newName": newName] guard let data = try? JSONEncoder().encode(body) else { return } var req = URLRequest(url: url); req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type"); req.httpBody = data if let (_, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 { ToastStore.shared.show("Renamed to \(newName)", style: .success) if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } } private func deleteFile(_ node: FileNode) async { guard let encoded = node.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(daemonConfig.baseURL)/files/delete?path=\(encoded)") else { return } var req = URLRequest(url: url); req.httpMethod = "DELETE" if let (_, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 204 { ToastStore.shared.show("Deleted \(node.name)", style: .warning) if let tab = editorState.tabs.first(where: { $0.path == node.path }) { editorState.closeTab(id: tab.id) } if let p = activeProject { await buildService.fetchFileTree(projectPath: p.rootPath) } } } } // MARK: - Console Resize Handle struct ConsoleResizeHandle: View { @Binding var height: CGFloat @State private var startHeight: CGFloat = 0 var body: some View { Rectangle().fill(Color(.systemFill)).frame(height: 6).contentShape(Rectangle()) .gesture(DragGesture() .onChanged { v in height = max(80, min(600, startHeight - v.translation.height)) } .onEnded { _ in startHeight = height }) .onAppear { startHeight = height } } } // MARK: - Project Picker Sheet struct ProjectPickerSheet: View { @ObservedObject var projectStore: ProjectStore @Binding var activeProject: SavedProject? let onSelect: (SavedProject) -> Void @Environment(\.dismiss) var dismiss @State private var showAdd = false var body: some View { NavigationStack { List { ForEach(projectStore.projects) { p in Button { activeProject = p; onSelect(p); dismiss() } label: { HStack { VStack(alignment:.leading, spacing:3) { Text(p.name).font(.headline).foregroundStyle(.primary) Text(p.rootPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) Text("Scheme: \(p.scheme)").font(.caption2).foregroundStyle(.tertiary) } Spacer() if p.id == activeProject?.id { Image(systemName:"checkmark.circle.fill").foregroundStyle(Color.accentColor) } } }.buttonStyle(.plain) } .onDelete { projectStore.remove(atOffsets: $0) } } .navigationTitle("Projects").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button { showAdd = true } label: { Image(systemName:"plus") } } } .sheet(isPresented: $showAdd) { AddProjectView(projectStore: projectStore) } } .presentationDetents([.medium, .large]) } }