Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/purepoint-macos/purepoint-macos/Models/EditorTab.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

struct EditorTab: Identifiable {
let id: String // absolute path
let name: String
var content: String
var isDirty: Bool
var lastModified: Date?
let isBinary: Bool
let language: EditorLanguage
}

enum EditorLanguage: String, CaseIterable {
case swift
case rust
case javascript
case typescript
case python
case markdown
case json
case yaml
case toml
case html
case css
case shell
case plaintext

static func detect(from filename: String) -> EditorLanguage {
let ext = (filename as NSString).pathExtension.lowercased()
switch ext {
case "swift": return .swift
case "rs": return .rust
case "js", "jsx", "mjs", "cjs": return .javascript
case "ts", "tsx", "mts", "cts": return .typescript
case "py", "pyi": return .python
case "md", "markdown": return .markdown
case "json": return .json
case "yml", "yaml": return .yaml
case "toml": return .toml
case "html", "htm": return .html
case "css", "scss", "sass", "less": return .css
case "sh", "bash", "zsh", "fish": return .shell
default: return .plaintext
}
}

var icon: String {
switch self {
case .swift: return "swift"
case .rust: return "gearshape.2"
case .javascript, .typescript: return "curlybraces"
case .python: return "chevron.left.forwardslash.chevron.right"
case .markdown: return "doc.text"
case .json, .yaml, .toml: return "doc.badge.gearshape"
case .html: return "globe"
case .css: return "paintbrush"
case .shell: return "terminal"
case .plaintext: return "doc"
}
}
}
36 changes: 36 additions & 0 deletions apps/purepoint-macos/purepoint-macos/Models/FileNode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

struct FileNode: Identifiable {
let id: String // relative path from worktree root
let name: String
let absolutePath: String
let isDirectory: Bool
var children: [FileNode]? // nil = unexpanded directory
}

// MARK: - FileTreeNode (NSOutlineView reference-type wrapper)

class FileTreeNode {
let name: String
let absolutePath: String
let relativePath: String
let isDirectory: Bool
var children: [FileTreeNode]

init(name: String, absolutePath: String, relativePath: String, isDirectory: Bool, children: [FileTreeNode] = []) {
self.name = name
self.absolutePath = absolutePath
self.relativePath = relativePath
self.isDirectory = isDirectory
self.children = children
}

var isExpandable: Bool { isDirectory }

static func sorted(_ nodes: [FileTreeNode]) -> [FileTreeNode] {
nodes.sorted { a, b in
if a.isDirectory != b.isDirectory { return a.isDirectory }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
}
}
}
33 changes: 33 additions & 0 deletions apps/purepoint-macos/purepoint-macos/Services/FileIOService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

enum FileIOService {
static func readFile(at path: String) async throws -> (content: String, isBinary: Bool) {
try await Task.detached {
let url = URL(fileURLWithPath: path)
let data = try Data(contentsOf: url)

// Binary detection: scan first 8KB for null bytes
let scanLength = min(data.count, 8192)
let prefix = data.prefix(scanLength)
if prefix.contains(0x00) {
return (content: "", isBinary: true)
}

guard let content = String(data: data, encoding: .utf8) else {
return (content: "", isBinary: true)
}
return (content: content, isBinary: false)
}.value
}

static func writeFile(content: String, to path: String) async throws {
try await Task.detached {
let url = URL(fileURLWithPath: path)
try content.write(to: url, atomically: true, encoding: .utf8)
}.value
}

static func fileModificationDate(at path: String) -> Date? {
try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation

/// Watches multiple directories for file system changes using GCD DispatchSource.
/// Follows the WorktreeWatcher pattern with debounced callbacks.
nonisolated final class FileTreeWatcher: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.purepoint.file-tree-watcher")
private var sources: [String: (source: DispatchSourceFileSystemObject, fd: Int32)] = [:]
private var debounceWork: DispatchWorkItem?
private let onChange: @Sendable () -> Void
private static let debounceInterval: TimeInterval = 0.5

init(onChange: @escaping @Sendable () -> Void) {
self.onChange = onChange
}

func watchDirectory(path: String) {
queue.async { [weak self] in
guard let self, self.sources[path] == nil else { return }

let fd = open(path, O_EVTONLY)
guard fd >= 0 else { return }

let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .rename, .delete, .attrib],
queue: self.queue
)

source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
self.queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self else { return }
if let entry = self.sources.removeValue(forKey: path) {
entry.source.cancel()
}
self.watchDirectory(path: path)
self.scheduleDebounce()
}
} else {
self.scheduleDebounce()
}
}

source.setCancelHandler {
close(fd)
}

source.resume()
self.sources[path] = (source: source, fd: fd)
}
}

func unwatchDirectory(path: String) {
queue.async { [weak self] in
guard let self, let entry = self.sources.removeValue(forKey: path) else { return }
entry.source.cancel()
}
}

func stopAll() {
queue.sync { [weak self] in
guard let self else { return }
self.debounceWork?.cancel()
for (_, entry) in self.sources {
entry.source.cancel()
}
self.sources.removeAll()
}
}

private func scheduleDebounce() {
debounceWork?.cancel()
let callback = onChange
let work = DispatchWorkItem {
DispatchQueue.main.async {
callback()
}
}
debounceWork = work
queue.asyncAfter(deadline: .now() + Self.debounceInterval, execute: work)
}

deinit {
queue.sync {
debounceWork?.cancel()
for (_, entry) in sources {
entry.source.cancel()
}
sources.removeAll()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import AppKit

/// Manages syntax highlighting for the code editor.
/// Currently a placeholder — tree-sitter integration (Neon/SwiftTreeSitter SPM packages)
/// will be added in a follow-up step. The editor works with plain monospace text until then.
@MainActor
final class SyntaxHighlightManager {
private weak var textView: NSTextView?
private var language: EditorLanguage = .plaintext

init(textView: NSTextView) {
self.textView = textView
}

func setLanguage(_ language: EditorLanguage) {
self.language = language
// TODO: Initialize tree-sitter parser for language
// TODO: Set up Neon TextViewHighlighter
}

func invalidate() {
// TODO: Re-highlight full document
}

func applyHighlighting() {
// TODO: Use Neon to apply incremental highlighting
// For now, just ensure the base text color is set
guard let textView, let textStorage = textView.textStorage else { return }
let fullRange = NSRange(location: 0, length: textStorage.length)
textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange)
textStorage.addAttribute(
.font,
value: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular),
range: fullRange
)
}
}
Loading
Loading