Files
CxIDE/Services/WorkspaceService.swift
T
cx-git-agent 6218a6ef28 feat: syntax highlighting, workspace search, git-tree wiring, auto-restore
- 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)
2026-04-21 16:38:42 -05:00

330 lines
10 KiB
Swift

import Foundation
import Combine
import SwiftUI
// MARK: - Workspace Service
@MainActor
final class WorkspaceService: ObservableObject {
@Published var currentWorkspace: Workspace?
@Published var state: WorkspaceState
@Published var fileTree: [FileNode] = []
@Published var isLoading: Bool = false
private let fileSystem = FileSystemService()
private var fileWatcher: DispatchSourceFileSystemObject?
private var watcherFD: Int32 = -1
init() {
self.state = WorkspaceState.load()
}
// MARK: - Open Workspace
func openWorkspace(at url: URL) {
let name = url.lastPathComponent
let workspace = Workspace(name: name, rootURL: url)
isLoading = true
currentWorkspace = workspace
// Build file tree
let excludePatterns = workspace.settings.excludePatterns
Task {
let tree = await fileSystem.buildFileTree(at: url, excludePatterns: excludePatterns)
fileTree = tree
isLoading = false
}
// Update recents
state.addRecent(workspace)
state.lastActiveWorkspaceID = workspace.id
state.save()
// Start watching for changes
startFileWatcher(at: url)
}
func openWorkspaceFromDialog() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.message = "Choose a folder to open as workspace"
panel.prompt = "Open"
if panel.runModal() == .OK, let url = panel.url {
openWorkspace(at: url)
}
}
func openRecentWorkspace(_ recent: WorkspaceState.RecentWorkspace) {
guard recent.exists else {
state.recentWorkspaces.removeAll { $0.id == recent.id }
state.save()
return
}
openWorkspace(at: recent.url)
}
func closeWorkspace() {
stopFileWatcher()
currentWorkspace = nil
fileTree = []
}
// MARK: - File Tree
func refreshFileTree() {
guard let workspace = currentWorkspace else { return }
isLoading = true
Task {
let tree = await fileSystem.buildFileTree(
at: workspace.rootURL,
excludePatterns: workspace.settings.excludePatterns
)
fileTree = tree
isLoading = false
}
}
func applyGitStatus(_ changes: [GitFileChange]) {
guard let rootURL = currentWorkspace?.rootURL else { return }
let rootPath = rootURL.path
// Build a lookup from relative path to status
var statusMap: [String: GitFileStatus] = [:]
for change in changes {
statusMap[change.path] = change.status
}
// Recursively apply to tree
func apply(to nodes: [FileNode]) {
for node in nodes {
let filePath = node.url.path
if filePath.hasPrefix(rootPath) {
let relative = String(filePath.dropFirst(rootPath.count + 1))
node.gitStatus = statusMap[relative]
}
if let children = node.children {
apply(to: children)
}
}
}
apply(to: fileTree)
}
func toggleExpansion(_ node: FileNode) {
guard node.isDirectory else { return }
node.isExpanded.toggle()
if node.isExpanded && (node.children == nil || node.children?.isEmpty == true) {
Task {
let excludePatterns = currentWorkspace?.settings.excludePatterns ?? []
let children = await fileSystem.loadChildren(of: node, excludePatterns: excludePatterns)
node.children = children
}
}
}
// MARK: - File Operations
func createFile(name: String, in directory: FileNode?) -> URL? {
let parentURL = directory?.url ?? currentWorkspace?.rootURL
guard let parent = parentURL else { return nil }
let fileURL = parent.appendingPathComponent(name)
return fileSystem.createFile(at: fileURL) ? fileURL : nil
}
func createFolder(name: String, in directory: FileNode?) -> URL? {
let parentURL = directory?.url ?? currentWorkspace?.rootURL
guard let parent = parentURL else { return nil }
let folderURL = parent.appendingPathComponent(name)
return fileSystem.createDirectory(at: folderURL) ? folderURL : nil
}
func deleteItem(_ node: FileNode) -> Bool {
fileSystem.deleteItem(at: node.url)
}
func renameItem(_ node: FileNode, to newName: String) -> URL? {
let newURL = node.url.deletingLastPathComponent().appendingPathComponent(newName)
return fileSystem.moveItem(from: node.url, to: newURL) ? newURL : nil
}
// MARK: - File Watching
private func startFileWatcher(at url: URL) {
stopFileWatcher()
watcherFD = open(url.path, O_EVTONLY)
guard watcherFD >= 0 else { return }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watcherFD,
eventMask: [.write, .rename, .delete],
queue: .main
)
source.setEventHandler { [weak self] in
self?.refreshFileTree()
}
source.setCancelHandler { [weak self] in
if let fd = self?.watcherFD, fd >= 0 {
close(fd)
}
}
source.resume()
fileWatcher = source
}
private func stopFileWatcher() {
fileWatcher?.cancel()
fileWatcher = nil
watcherFD = -1
}
// MARK: - Workspace Settings
func updateSettings(_ update: (inout WorkspaceSettings) -> Void) {
guard var workspace = currentWorkspace else { return }
update(&workspace.settings)
currentWorkspace = workspace
saveWorkspaceState()
}
private func saveWorkspaceState() {
state.save()
}
}
// MARK: - File System Service
final class FileSystemService: @unchecked Sendable {
private let fileManager = FileManager.default
func buildFileTree(at url: URL, excludePatterns: [String]) async -> [FileNode] {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let nodes = self.loadDirectory(at: url, excludePatterns: excludePatterns, depth: 0, maxDepth: 2)
continuation.resume(returning: nodes)
}
}
}
func loadChildren(of node: FileNode, excludePatterns: [String]) async -> [FileNode] {
guard node.isDirectory else { return [] }
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let children = self.loadDirectory(
at: node.url, excludePatterns: excludePatterns,
depth: node.depth + 1, maxDepth: node.depth + 2
)
continuation.resume(returning: children)
}
}
}
private func loadDirectory(at url: URL, excludePatterns: [String], depth: Int, maxDepth: Int) -> [FileNode] {
guard let contents = try? fileManager.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else { return [] }
var dirs: [FileNode] = []
var files: [FileNode] = []
for itemURL in contents {
let name = itemURL.lastPathComponent
// Skip excluded patterns
if excludePatterns.contains(where: { name == $0 || name.hasSuffix($0) }) {
continue
}
let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey])
let isDir = resourceValues?.isDirectory ?? false
let node = FileNode(url: itemURL, isDirectory: isDir)
node.depth = depth
if isDir {
if depth < maxDepth {
node.children = loadDirectory(
at: itemURL, excludePatterns: excludePatterns,
depth: depth + 1, maxDepth: maxDepth
)
node.isExpanded = depth == 0
} else {
node.children = []
}
dirs.append(node)
} else {
files.append(node)
}
}
// Directories first, then files, both alphabetical
dirs.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
files.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
return dirs + files
}
// MARK: - CRUD
func readFile(at url: URL) -> String? {
try? String(contentsOf: url, encoding: .utf8)
}
func writeFile(content: String, to url: URL) -> Bool {
do {
try content.write(to: url, atomically: true, encoding: .utf8)
return true
} catch {
return false
}
}
func createFile(at url: URL, content: String = "") -> Bool {
guard !fileManager.fileExists(atPath: url.path) else { return false }
return fileManager.createFile(atPath: url.path, contents: content.data(using: .utf8))
}
func createDirectory(at url: URL) -> Bool {
do {
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
return true
} catch {
return false
}
}
func deleteItem(at url: URL) -> Bool {
do {
try fileManager.removeItem(at: url)
return true
} catch {
return false
}
}
func moveItem(from: URL, to: URL) -> Bool {
do {
try fileManager.moveItem(at: from, to: to)
return true
} catch {
return false
}
}
func fileExists(at url: URL) -> Bool {
fileManager.fileExists(atPath: url.path)
}
func isDirectory(at url: URL) -> Bool {
var isDir: ObjCBool = false
fileManager.fileExists(atPath: url.path, isDirectory: &isDir)
return isDir.boolValue
}
}