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)
330 lines
10 KiB
Swift
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
|
|
}
|
|
}
|