diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 881d926..24f6801 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -44,6 +44,50 @@ jobs: - name: Install xcbeautify run: brew install xcbeautify + - name: Clone local tree-sitter packages + run: | + mkdir -p apps/purepoint-macos/LocalPackages + git clone --depth 1 https://github.com/tree-sitter/tree-sitter-css.git apps/purepoint-macos/LocalPackages/tree-sitter-css + git clone --depth 1 https://github.com/tree-sitter/tree-sitter-javascript.git apps/purepoint-macos/LocalPackages/tree-sitter-javascript + git clone --depth 1 https://github.com/tree-sitter-grammars/tree-sitter-lua.git apps/purepoint-macos/LocalPackages/tree-sitter-lua + git clone --depth 1 https://github.com/tree-sitter/tree-sitter-python.git apps/purepoint-macos/LocalPackages/tree-sitter-python + git clone --depth 1 https://github.com/tree-sitter-grammars/tree-sitter-yaml.git apps/purepoint-macos/LocalPackages/tree-sitter-yaml + + - name: Prepare local packages for Xcode + run: | + # Xcode 26 ignores the explicit `sources` parameter in local SPM packages, + # so restructure each package to use path-based auto-discovery instead. + for pkg in apps/purepoint-macos/LocalPackages/tree-sitter-*; do + lib=$(sed -n 's/.*name: "\(TreeSitter[^"]*\)".*/\1/p' "$pkg/Package.swift" | head -1) + # Copy public header into src/include/ + mkdir -p "$pkg/src/include" + find "$pkg/bindings/swift" -name '*.h' -exec cp {} "$pkg/src/include/" \; + # Copy queries into src/ so resources path works with path: "src" + cp -r "$pkg/queries" "$pkg/src/queries" 2>/dev/null || true + # Remove non-C/H files from src/ (JSON metadata confuses SPM) + find "$pkg/src" -type f -not -name '*.c' -not -name '*.h' -not -name '*.scm' -delete + find "$pkg/src" -type d -empty -delete 2>/dev/null || true + # Rewrite Package.swift: use path "src" so SPM auto-discovers all .c files + cat > "$pkg/Package.swift" << PKGEOF + // swift-tools-version:5.3 + import PackageDescription + let package = Package( + name: "$lib", + products: [.library(name: "$lib", targets: ["$lib"])], + targets: [ + .target( + name: "$lib", + path: "src", + resources: [.copy("queries")], + publicHeadersPath: "include", + cSettings: [.headerSearchPath(".")] + ), + ], + cLanguageStandard: .c11 + ) + PKGEOF + done + - name: Build run: | xcodebuild build \ diff --git a/.gitignore b/.gitignore index 3a1b7fe..9965cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,8 @@ build/ .ppg/swarms/ .ppg/conductor-context.md +# Tree-sitter local overrides (cloned per-developer, not project source) +apps/purepoint-macos/LocalPackages/ + # PurePoint runtime .pu/ diff --git a/apps/purepoint-macos/purepoint-macos.xcodeproj/project.pbxproj b/apps/purepoint-macos/purepoint-macos.xcodeproj/project.pbxproj index e9d44f7..0c5d1d0 100644 --- a/apps/purepoint-macos/purepoint-macos.xcodeproj/project.pbxproj +++ b/apps/purepoint-macos/purepoint-macos.xcodeproj/project.pbxproj @@ -9,6 +9,28 @@ /* Begin PBXBuildFile section */ FB0A00012F60000000000001 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = FB0A00022F60000000000001 /* SwiftTerm */; }; FB0A00062F60000000000002 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = FB0A00072F60000000000002 /* Sparkle */; }; + FB57075A2F6834CC0077135F /* TreeSitterPHP in Frameworks */ = {isa = PBXBuildFile; productRef = FB5707592F6834CC0077135F /* TreeSitterPHP */; }; + FB57076B2F6840320077135F /* TreeSitterMarkdown in Frameworks */ = {isa = PBXBuildFile; productRef = FB57076A2F6840320077135F /* TreeSitterMarkdown */; }; + FB57076E2F68417C0077135F /* TreeSitterHTML in Frameworks */ = {isa = PBXBuildFile; productRef = FB57076D2F68417C0077135F /* TreeSitterHTML */; }; + FB5707742F6841F30077135F /* TreeSitterGo in Frameworks */ = {isa = PBXBuildFile; productRef = FB5707732F6841F30077135F /* TreeSitterGo */; }; + FB5707772F6842360077135F /* TreeSitterRuby in Frameworks */ = {isa = PBXBuildFile; productRef = FB5707762F6842360077135F /* TreeSitterRuby */; }; + FB57077A2F6842540077135F /* TreeSitterScala in Frameworks */ = {isa = PBXBuildFile; productRef = FB5707792F6842540077135F /* TreeSitterScala */; }; + FB57077D2F6842700077135F /* TreeSitterHaskell in Frameworks */ = {isa = PBXBuildFile; productRef = FB57077C2F6842700077135F /* TreeSitterHaskell */; }; + FB5707802F6842C30077135F /* TreeSitterTOML in Frameworks */ = {isa = PBXBuildFile; productRef = FB57077F2F6842C30077135F /* TreeSitterTOML */; }; + FB5707832F68430B0077135F /* TreeSitterXML in Frameworks */ = {isa = PBXBuildFile; productRef = FB5707822F68430B0077135F /* TreeSitterXML */; }; + FB57078D2F6847D90077135F /* TreeSitterCSharp in Frameworks */ = {isa = PBXBuildFile; productRef = FB57078C2F6847D90077135F /* TreeSitterCSharp */; }; + FB57078F2F6847F10077135F /* TreeSitterCPP in Frameworks */ = {isa = PBXBuildFile; productRef = FB57078E2F6847F10077135F /* TreeSitterCPP */; }; + FB5726972F6851440077135F /* TreeSitterYAML in Frameworks */ = {isa = PBXBuildFile; productRef = FB5726962F6851440077135F /* TreeSitterYAML */; }; + FB57269A2F68515E0077135F /* TreeSitterLua in Frameworks */ = {isa = PBXBuildFile; productRef = FB5726992F68515E0077135F /* TreeSitterLua */; }; + FB57269D2F6851B90077135F /* TreeSitterCSS in Frameworks */ = {isa = PBXBuildFile; productRef = FB57269C2F6851B90077135F /* TreeSitterCSS */; }; + FB5726A02F6851DA0077135F /* TreeSitterPython in Frameworks */ = {isa = PBXBuildFile; productRef = FB57269F2F6851DA0077135F /* TreeSitterPython */; }; + FB5726A32F6851EE0077135F /* TreeSitterJavaScript in Frameworks */ = {isa = PBXBuildFile; productRef = FB5726A22F6851EE0077135F /* TreeSitterJavaScript */; }; + FB57E1282F67B8090077135F /* TreeSitterSwift in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E1272F67B8090077135F /* TreeSitterSwift */; }; + FB57E12F2F67BC020077135F /* Neon in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E12E2F67BC020077135F /* Neon */; }; + FB57E1322F67BCE90077135F /* TreeSitterRust in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E1312F67BCE90077135F /* TreeSitterRust */; }; + FB57E1352F67BD090077135F /* TreeSitterJSON in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E1342F67BD090077135F /* TreeSitterJSON */; }; + FB57E1382F67BD5B0077135F /* TreeSitterBash in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E1372F67BD5B0077135F /* TreeSitterBash */; }; + FB57E13B2F67BD900077135F /* TreeSitterTypeScript in Frameworks */ = {isa = PBXBuildFile; productRef = FB57E13A2F67BD900077135F /* TreeSitterTypeScript */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,8 +79,30 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FB57E12F2F67BC020077135F /* Neon in Frameworks */, FB0A00012F60000000000001 /* SwiftTerm in Frameworks */, FB0A00062F60000000000002 /* Sparkle in Frameworks */, + FB57E1352F67BD090077135F /* TreeSitterJSON in Frameworks */, + FB57E1322F67BCE90077135F /* TreeSitterRust in Frameworks */, + FB5726A02F6851DA0077135F /* TreeSitterPython in Frameworks */, + FB57269A2F68515E0077135F /* TreeSitterLua in Frameworks */, + FB57E1282F67B8090077135F /* TreeSitterSwift in Frameworks */, + FB5726972F6851440077135F /* TreeSitterYAML in Frameworks */, + FB5726A32F6851EE0077135F /* TreeSitterJavaScript in Frameworks */, + FB57E1382F67BD5B0077135F /* TreeSitterBash in Frameworks */, + FB57269D2F6851B90077135F /* TreeSitterCSS in Frameworks */, + FB57E13B2F67BD900077135F /* TreeSitterTypeScript in Frameworks */, + FB57075A2F6834CC0077135F /* TreeSitterPHP in Frameworks */, + FB57076B2F6840320077135F /* TreeSitterMarkdown in Frameworks */, + FB57076E2F68417C0077135F /* TreeSitterHTML in Frameworks */, + FB5707742F6841F30077135F /* TreeSitterGo in Frameworks */, + FB5707772F6842360077135F /* TreeSitterRuby in Frameworks */, + FB57077A2F6842540077135F /* TreeSitterScala in Frameworks */, + FB57077D2F6842700077135F /* TreeSitterHaskell in Frameworks */, + FB5707802F6842C30077135F /* TreeSitterTOML in Frameworks */, + FB5707832F68430B0077135F /* TreeSitterXML in Frameworks */, + FB57078D2F6847D90077135F /* TreeSitterCSharp in Frameworks */, + FB57078F2F6847F10077135F /* TreeSitterCPP in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -122,6 +166,28 @@ packageProductDependencies = ( FB0A00022F60000000000001 /* SwiftTerm */, FB0A00072F60000000000002 /* Sparkle */, + FB57E1272F67B8090077135F /* TreeSitterSwift */, + FB57E12E2F67BC020077135F /* Neon */, + FB57E1312F67BCE90077135F /* TreeSitterRust */, + FB57E1342F67BD090077135F /* TreeSitterJSON */, + FB57E1372F67BD5B0077135F /* TreeSitterBash */, + FB57E13A2F67BD900077135F /* TreeSitterTypeScript */, + FB5726962F6851440077135F /* TreeSitterYAML */, + FB5726992F68515E0077135F /* TreeSitterLua */, + FB57269C2F6851B90077135F /* TreeSitterCSS */, + FB57269F2F6851DA0077135F /* TreeSitterPython */, + FB5726A22F6851EE0077135F /* TreeSitterJavaScript */, + FB5707592F6834CC0077135F /* TreeSitterPHP */, + FB57076A2F6840320077135F /* TreeSitterMarkdown */, + FB57076D2F68417C0077135F /* TreeSitterHTML */, + FB5707732F6841F30077135F /* TreeSitterGo */, + FB5707762F6842360077135F /* TreeSitterRuby */, + FB5707792F6842540077135F /* TreeSitterScala */, + FB57077C2F6842700077135F /* TreeSitterHaskell */, + FB57077F2F6842C30077135F /* TreeSitterTOML */, + FB5707822F68430B0077135F /* TreeSitterXML */, + FB57078C2F6847D90077135F /* TreeSitterCSharp */, + FB57078E2F6847F10077135F /* TreeSitterCPP */, ); productName = "purepoint-macos"; productReference = FBC7CFBA2F53F1FD00F8E0A1 /* purepoint-macos.app */; @@ -208,6 +274,28 @@ packageReferences = ( FB0A00032F60000000000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */, FB0A00082F60000000000002 /* XCRemoteSwiftPackageReference "Sparkle" */, + FB57E1262F67B8090077135F /* XCRemoteSwiftPackageReference "tree-sitter-swift" */, + FB57E12C2F67BB8F0077135F /* XCRemoteSwiftPackageReference "tree-sitter-typescript" */, + FB57E12D2F67BC020077135F /* XCRemoteSwiftPackageReference "Neon" */, + FB57E1302F67BCE90077135F /* XCRemoteSwiftPackageReference "tree-sitter-rust" */, + FB57E1332F67BD090077135F /* XCRemoteSwiftPackageReference "tree-sitter-json" */, + FB57E1362F67BD5B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-bash" */, + FB5726952F6851440077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-yaml" */, + FB5726982F68515E0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-lua" */, + FB57269B2F6851B90077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-css" */, + FB57269E2F6851DA0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-python" */, + FB5726A12F6851EE0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-javascript" */, + FB5707582F6834CC0077135F /* XCRemoteSwiftPackageReference "tree-sitter-php" */, + FB5707692F6840320077135F /* XCRemoteSwiftPackageReference "tree-sitter-markdown" */, + FB57076C2F68417C0077135F /* XCRemoteSwiftPackageReference "tree-sitter-html" */, + FB5707722F6841F30077135F /* XCRemoteSwiftPackageReference "tree-sitter-go" */, + FB5707752F6842360077135F /* XCRemoteSwiftPackageReference "tree-sitter-ruby" */, + FB5707782F6842540077135F /* XCRemoteSwiftPackageReference "tree-sitter-scala" */, + FB57077B2F6842700077135F /* XCRemoteSwiftPackageReference "tree-sitter-haskell" */, + FB57077E2F6842C30077135F /* XCRemoteSwiftPackageReference "tree-sitter-toml" */, + FB5707812F68430B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-xml" */, + FB57078B2F6847D90077135F /* XCRemoteSwiftPackageReference "tree-sitter-c-sharp" */, + FB57078D2F6847F10077135F /* XCRemoteSwiftPackageReference "tree-sitter-cpp" */, ); preferredProjectObjectVersion = 77; productRefGroup = FBC7CFBB2F53F1FD00F8E0A1 /* Products */; @@ -635,6 +723,29 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + FB5726952F6851440077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-yaml" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "LocalPackages/tree-sitter-yaml"; + }; + FB5726982F68515E0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-lua" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "LocalPackages/tree-sitter-lua"; + }; + FB57269B2F6851B90077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-css" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "LocalPackages/tree-sitter-css"; + }; + FB57269E2F6851DA0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-python" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "LocalPackages/tree-sitter-python"; + }; + FB5726A12F6851EE0077135F /* XCLocalSwiftPackageReference "LocalPackages/tree-sitter-javascript" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "LocalPackages/tree-sitter-javascript"; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ FB0A00032F60000000000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; @@ -652,6 +763,142 @@ minimumVersion = 2.7.0; }; }; + FB5707582F6834CC0077135F /* XCRemoteSwiftPackageReference "tree-sitter-php" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-php"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB5707692F6840320077135F /* XCRemoteSwiftPackageReference "tree-sitter-markdown" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter-grammars/tree-sitter-markdown"; + requirement = { + branch = split_parser; + kind = branch; + }; + }; + FB57076C2F68417C0077135F /* XCRemoteSwiftPackageReference "tree-sitter-html" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-html"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB5707722F6841F30077135F /* XCRemoteSwiftPackageReference "tree-sitter-go" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-go"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB5707752F6842360077135F /* XCRemoteSwiftPackageReference "tree-sitter-ruby" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-ruby"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB5707782F6842540077135F /* XCRemoteSwiftPackageReference "tree-sitter-scala" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-scala"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57077B2F6842700077135F /* XCRemoteSwiftPackageReference "tree-sitter-haskell" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-haskell"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57077E2F6842C30077135F /* XCRemoteSwiftPackageReference "tree-sitter-toml" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter-grammars/tree-sitter-toml"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB5707812F68430B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-xml" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter-grammars/tree-sitter-xml"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57078B2F6847D90077135F /* XCRemoteSwiftPackageReference "tree-sitter-c-sharp" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-c-sharp"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57078D2F6847F10077135F /* XCRemoteSwiftPackageReference "tree-sitter-cpp" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-cpp"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57E1262F67B8090077135F /* XCRemoteSwiftPackageReference "tree-sitter-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/alex-pinkus/tree-sitter-swift"; + requirement = { + kind = revision; + revision = 277b583bbb024f20ba88b95c48bf5a6a0b4f2287; + }; + }; + FB57E12C2F67BB8F0077135F /* XCRemoteSwiftPackageReference "tree-sitter-typescript" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-typescript"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57E12D2F67BC020077135F /* XCRemoteSwiftPackageReference "Neon" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ChimeHQ/Neon"; + requirement = { + branch = main; + kind = branch; + }; + }; + FB57E1302F67BCE90077135F /* XCRemoteSwiftPackageReference "tree-sitter-rust" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-rust"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57E1332F67BD090077135F /* XCRemoteSwiftPackageReference "tree-sitter-json" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-json"; + requirement = { + branch = master; + kind = branch; + }; + }; + FB57E1362F67BD5B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-bash" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tree-sitter/tree-sitter-bash"; + requirement = { + branch = master; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -665,6 +912,111 @@ package = FB0A00082F60000000000002 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + FB5707592F6834CC0077135F /* TreeSitterPHP */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707582F6834CC0077135F /* XCRemoteSwiftPackageReference "tree-sitter-php" */; + productName = TreeSitterPHP; + }; + FB57076A2F6840320077135F /* TreeSitterMarkdown */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707692F6840320077135F /* XCRemoteSwiftPackageReference "tree-sitter-markdown" */; + productName = TreeSitterMarkdown; + }; + FB57076D2F68417C0077135F /* TreeSitterHTML */ = { + isa = XCSwiftPackageProductDependency; + package = FB57076C2F68417C0077135F /* XCRemoteSwiftPackageReference "tree-sitter-html" */; + productName = TreeSitterHTML; + }; + FB5707732F6841F30077135F /* TreeSitterGo */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707722F6841F30077135F /* XCRemoteSwiftPackageReference "tree-sitter-go" */; + productName = TreeSitterGo; + }; + FB5707762F6842360077135F /* TreeSitterRuby */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707752F6842360077135F /* XCRemoteSwiftPackageReference "tree-sitter-ruby" */; + productName = TreeSitterRuby; + }; + FB5707792F6842540077135F /* TreeSitterScala */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707782F6842540077135F /* XCRemoteSwiftPackageReference "tree-sitter-scala" */; + productName = TreeSitterScala; + }; + FB57077C2F6842700077135F /* TreeSitterHaskell */ = { + isa = XCSwiftPackageProductDependency; + package = FB57077B2F6842700077135F /* XCRemoteSwiftPackageReference "tree-sitter-haskell" */; + productName = TreeSitterHaskell; + }; + FB57077F2F6842C30077135F /* TreeSitterTOML */ = { + isa = XCSwiftPackageProductDependency; + package = FB57077E2F6842C30077135F /* XCRemoteSwiftPackageReference "tree-sitter-toml" */; + productName = TreeSitterTOML; + }; + FB5707822F68430B0077135F /* TreeSitterXML */ = { + isa = XCSwiftPackageProductDependency; + package = FB5707812F68430B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-xml" */; + productName = TreeSitterXML; + }; + FB57078C2F6847D90077135F /* TreeSitterCSharp */ = { + isa = XCSwiftPackageProductDependency; + package = FB57078B2F6847D90077135F /* XCRemoteSwiftPackageReference "tree-sitter-c-sharp" */; + productName = TreeSitterCSharp; + }; + FB57078E2F6847F10077135F /* TreeSitterCPP */ = { + isa = XCSwiftPackageProductDependency; + package = FB57078D2F6847F10077135F /* XCRemoteSwiftPackageReference "tree-sitter-cpp" */; + productName = TreeSitterCPP; + }; + FB5726962F6851440077135F /* TreeSitterYAML */ = { + isa = XCSwiftPackageProductDependency; + productName = TreeSitterYAML; + }; + FB5726992F68515E0077135F /* TreeSitterLua */ = { + isa = XCSwiftPackageProductDependency; + productName = TreeSitterLua; + }; + FB57269C2F6851B90077135F /* TreeSitterCSS */ = { + isa = XCSwiftPackageProductDependency; + productName = TreeSitterCSS; + }; + FB57269F2F6851DA0077135F /* TreeSitterPython */ = { + isa = XCSwiftPackageProductDependency; + productName = TreeSitterPython; + }; + FB5726A22F6851EE0077135F /* TreeSitterJavaScript */ = { + isa = XCSwiftPackageProductDependency; + productName = TreeSitterJavaScript; + }; + FB57E1272F67B8090077135F /* TreeSitterSwift */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E1262F67B8090077135F /* XCRemoteSwiftPackageReference "tree-sitter-swift" */; + productName = TreeSitterSwift; + }; + FB57E12E2F67BC020077135F /* Neon */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E12D2F67BC020077135F /* XCRemoteSwiftPackageReference "Neon" */; + productName = Neon; + }; + FB57E1312F67BCE90077135F /* TreeSitterRust */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E1302F67BCE90077135F /* XCRemoteSwiftPackageReference "tree-sitter-rust" */; + productName = TreeSitterRust; + }; + FB57E1342F67BD090077135F /* TreeSitterJSON */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E1332F67BD090077135F /* XCRemoteSwiftPackageReference "tree-sitter-json" */; + productName = TreeSitterJSON; + }; + FB57E1372F67BD5B0077135F /* TreeSitterBash */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E1362F67BD5B0077135F /* XCRemoteSwiftPackageReference "tree-sitter-bash" */; + productName = TreeSitterBash; + }; + FB57E13A2F67BD900077135F /* TreeSitterTypeScript */ = { + isa = XCSwiftPackageProductDependency; + package = FB57E12C2F67BB8F0077135F /* XCRemoteSwiftPackageReference "tree-sitter-typescript" */; + productName = TreeSitterTypeScript; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = FBC7CFB22F53F1FD00F8E0A1 /* Project object */; diff --git a/apps/purepoint-macos/purepoint-macos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/purepoint-macos/purepoint-macos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0c91ede..18fb59e 100644 --- a/apps/purepoint-macos/purepoint-macos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/purepoint-macos/purepoint-macos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "0990ca7c5e4f124cd5240435240c7118ac85fcbc4380f6246af7d5f738d096af", + "originHash" : "4c9f74fbd04a84ac253818befaea35817b120b5d267ffeff47b9a1d44dd1b6d2", "pins" : [ + { + "identity" : "neon", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/Neon", + "state" : { + "branch" : "main", + "revision" : "56bd2b1febeab0e10e72e2491746eda6787165b6" + } + }, + { + "identity" : "rearrange", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/Rearrange", + "state" : { + "revision" : "f1d74e1642956f0300756ad8d1d64e9034857bc3", + "version" : "2.0.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -27,6 +45,167 @@ "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", "version" : "1.11.2" } + }, + { + "identity" : "swifttreesitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/SwiftTreeSitter", + "state" : { + "branch" : "main", + "revision" : "f97df585296977d8fcaf644cbde567151d1367b8" + } + }, + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter", + "state" : { + "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406", + "version" : "0.25.10" + } + }, + { + "identity" : "tree-sitter-bash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-bash", + "state" : { + "branch" : "master", + "revision" : "a06c2e4415e9bc0346c6b86d401879ffb44058f7" + } + }, + { + "identity" : "tree-sitter-c-sharp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-c-sharp", + "state" : { + "branch" : "master", + "revision" : "88366631d598ce6595ec655ce1591b315cffb14c" + } + }, + { + "identity" : "tree-sitter-cpp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-cpp", + "state" : { + "branch" : "master", + "revision" : "8b5b49eb196bec7040441bee33b2c9a4838d6967" + } + }, + { + "identity" : "tree-sitter-go", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-go", + "state" : { + "branch" : "master", + "revision" : "2346a3ab1bb3857b48b29d779a1ef9799a248cd7" + } + }, + { + "identity" : "tree-sitter-haskell", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-haskell", + "state" : { + "branch" : "master", + "revision" : "0975ef72fc3c47b530309ca93937d7d143523628" + } + }, + { + "identity" : "tree-sitter-html", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-html", + "state" : { + "branch" : "master", + "revision" : "73a3947324f6efddf9e17c0ea58d454843590cc0" + } + }, + { + "identity" : "tree-sitter-json", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-json", + "state" : { + "branch" : "master", + "revision" : "001c28d7a29832b06b0e831ec77845553c89b56d" + } + }, + { + "identity" : "tree-sitter-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter-grammars/tree-sitter-markdown", + "state" : { + "branch" : "split_parser", + "revision" : "f969cd3ae3f9fbd4e43205431d0ae286014c05b5" + } + }, + { + "identity" : "tree-sitter-php", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-php", + "state" : { + "branch" : "master", + "revision" : "015ce839db5ae9ceda763bf12e071867fbe8cc89" + } + }, + { + "identity" : "tree-sitter-ruby", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-ruby", + "state" : { + "branch" : "master", + "revision" : "ad907a69da0c8a4f7a943a7fe012712208da6dee" + } + }, + { + "identity" : "tree-sitter-rust", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-rust", + "state" : { + "branch" : "master", + "revision" : "261b20226c04ef601adbdf185a800512a5f66291" + } + }, + { + "identity" : "tree-sitter-scala", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-scala", + "state" : { + "branch" : "master", + "revision" : "14c5cfd2b8e0f057ba0f4f72ee4812b0ae6cdce3" + } + }, + { + "identity" : "tree-sitter-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alex-pinkus/tree-sitter-swift", + "state" : { + "revision" : "277b583bbb024f20ba88b95c48bf5a6a0b4f2287" + } + }, + { + "identity" : "tree-sitter-toml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter-grammars/tree-sitter-toml", + "state" : { + "branch" : "master", + "revision" : "64b56832c2cffe41758f28e05c756a3a98d16f41" + } + }, + { + "identity" : "tree-sitter-typescript", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter-typescript", + "state" : { + "branch" : "master", + "revision" : "75b3874edb2dc714fb1fd77a32013d0f8699989f" + } + }, + { + "identity" : "tree-sitter-xml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter-grammars/tree-sitter-xml", + "state" : { + "branch" : "master", + "revision" : "5000ae8f22d11fbe93939b05c1e37cf21117162d" + } } ], "version" : 3 diff --git a/apps/purepoint-macos/purepoint-macos/Models/EditorTab.swift b/apps/purepoint-macos/purepoint-macos/Models/EditorTab.swift index 8d8870c..bc4b037 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/EditorTab.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/EditorTab.swift @@ -15,6 +15,7 @@ enum EditorLanguage: String, CaseIterable { case rust case javascript case typescript + case tsx case python case markdown case json @@ -23,38 +24,116 @@ enum EditorLanguage: String, CaseIterable { case html case css case shell + case c + case cpp + case go + case ruby + case php + case java + case csharp + case scala + case haskell + case lua + case xml case plaintext static func detect(from filename: String) -> EditorLanguage { + let name = (filename as NSString).lastPathComponent.lowercased() + + if let match = knownFilenames[name] { return match } + 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 "ts", "mts", "cts": return .typescript + case "tsx": return .tsx case "py", "pyi": return .python - case "md", "markdown": return .markdown - case "json": return .json + case "md", "markdown", "mdx": return .markdown + case "json", "jsonc", "json5": 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 + case "c", "h": return .c + case "cpp", "cc", "cxx", "hpp", "hxx", "hh": return .cpp + case "go": return .go + case "rb", "rake", "gemspec": return .ruby + case "php": return .php + case "java": return .java + case "cs": return .csharp + case "scala", "sc": return .scala + case "hs", "lhs": return .haskell + case "lua": return .lua + case "xml", "xsl", "xslt", "svg", "plist": return .xml + case "graphql", "gql": return .plaintext + case "dockerfile": return .shell default: return .plaintext } } + private static let knownFilenames: [String: EditorLanguage] = [ + // Lock files + "cargo.lock": .toml, + "package.resolved": .json, + "composer.lock": .json, + // Ruby conventions + "gemfile": .ruby, + "rakefile": .ruby, + "podfile": .ruby, + "vagrantfile": .ruby, + "fastfile": .ruby, + "brewfile": .ruby, + "dangerfile": .ruby, + "guardfile": .ruby, + // Build/task files + "makefile": .shell, + "gnumakefile": .shell, + "dockerfile": .shell, + "justfile": .shell, + "procfile": .shell, + // Ignore files + ".gitignore": .shell, + ".dockerignore": .shell, + ".npmignore": .shell, + ".hgignore": .shell, + // Config dotfiles + ".editorconfig": .plaintext, + ".gitconfig": .plaintext, + ".gitmodules": .plaintext, + // Shell dotfiles + ".zshrc": .shell, + ".bashrc": .shell, + ".bash_profile": .shell, + ".profile": .shell, + ".zprofile": .shell, + ".zshenv": .shell, + ".bash_aliases": .shell, + ".bash_logout": .shell, + ] + var icon: String { switch self { case .swift: return "swift" case .rust: return "gearshape.2" - case .javascript, .typescript: return "curlybraces" + case .javascript, .typescript, .tsx: 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 .html, .xml: return "globe" case .css: return "paintbrush" case .shell: return "terminal" + case .c, .cpp: return "c.square" + case .go: return "arrow.right.arrow.left" + case .ruby: return "diamond" + case .php: return "server.rack" + case .java: return "cup.and.saucer" + case .csharp: return "number" + case .scala: return "s.square" + case .haskell: return "function" + case .lua: return "moon" case .plaintext: return "doc" } } diff --git a/apps/purepoint-macos/purepoint-macos/Services/GitService.swift b/apps/purepoint-macos/purepoint-macos/Services/GitService.swift index 696750c..fe3f529 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/GitService.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/GitService.swift @@ -256,9 +256,7 @@ actor GitService { if let cached = cachedGhPath { ghPath = cached } else { - let whichResult = runProcess("/usr/bin/env", args: ["which", "gh"], cwd: cwd) - let resolved = whichResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - guard whichResult.success, !resolved.isEmpty else { + guard let resolved = locateGh() else { return CommandResult(stdout: "", stderr: "gh not found", exitCode: 1) } cachedGhPath = resolved @@ -267,6 +265,40 @@ actor GitService { return runProcess(ghPath, args: args, cwd: cwd) } + private nonisolated func locateGh() -> String? { + let candidates = [ + "/opt/homebrew/bin/gh", + "/usr/local/bin/gh", + ] + for path in candidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["gh"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty && FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + private func runProcess(_ path: String, args: [String], cwd: String) -> CommandResult { let process = Process() process.executableURL = URL(fileURLWithPath: path) diff --git a/apps/purepoint-macos/purepoint-macos/Services/SyntaxHighlightManager.swift b/apps/purepoint-macos/purepoint-macos/Services/SyntaxHighlightManager.swift index 6a5ce51..90c94fc 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/SyntaxHighlightManager.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/SyntaxHighlightManager.swift @@ -1,37 +1,371 @@ import AppKit +import Neon +import SwiftTreeSitter +import TreeSitterSwift +import TreeSitterRust +import TreeSitterJavaScript +import TreeSitterTypeScript +import TreeSitterTSX +import TreeSitterPython +import TreeSitterJSON +import TreeSitterBash +import TreeSitterHTML +import TreeSitterCSS +import TreeSitterYAML +import TreeSitterTOML +import TreeSitterMarkdown +import TreeSitterMarkdownInline +import TreeSitterCPP +import TreeSitterGo +import TreeSitterRuby +import TreeSitterPHP +import TreeSitterCSharp +import TreeSitterScala +import TreeSitterHaskell +import TreeSitterLua +import TreeSitterXML -/// 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 + private var highlighter: TextViewHighlighter? + private var currentLanguage: EditorLanguage = .plaintext + + private static let maxHighlightSize = 1_000_000 + private static var languageConfigs: [EditorLanguage: LanguageConfiguration] = [:] + private static let regularFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + private static let boldFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .bold) + private static let italicFont: NSFont = { + NSFontManager.shared.convert(regularFont, toHaveTrait: .italicFontMask) + }() 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 + guard language != currentLanguage else { return } + currentLanguage = language + rebuildHighlighter() } 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 + rebuildHighlighter() + } + + // MARK: - Private + + private func rebuildHighlighter() { + highlighter = nil + + guard let textView else { + clearTemporaryAttributes() + return + } + + if textView.string.utf8.count > Self.maxHighlightSize { + clearTemporaryAttributes() + return + } + + guard let langConfig = Self.languageConfiguration(for: currentLanguage) else { + clearTemporaryAttributes() + return + } + + do { + let config = TextViewHighlighter.Configuration( + languageConfiguration: langConfig, + attributeProvider: Self.attributeProvider, + languageProvider: { name in + Self.languageConfigurationForInjection(name) + }, + locationTransformer: { [weak textView] offset in + guard let textView, let storage = textView.textStorage else { return nil } + let string = storage.string as NSString + guard offset <= string.length else { return nil } + let head = string.substring(to: offset) + let lines = head.components(separatedBy: "\n") + let row = lines.count - 1 + let col = (lines.last ?? "").utf16.count + return Point(row: row, column: col) + } + ) + highlighter = try TextViewHighlighter(textView: textView, configuration: config) + } catch { + clearTemporaryAttributes() + } + } + + private func clearTemporaryAttributes() { + guard let textView, let layoutManager = textView.layoutManager, + let storage = textView.textStorage, storage.length > 0 + else { return } + let range = NSRange(location: 0, length: storage.length) + layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: range) + layoutManager.removeTemporaryAttribute(.font, forCharacterRange: range) + } + + // MARK: - Language Configuration Registry + + private static func languageConfiguration(for language: EditorLanguage) -> LanguageConfiguration? { + if let cached = languageConfigs[language] { return cached } + + let config: LanguageConfiguration? + do { + switch language { + case .swift: + config = try LanguageConfiguration(tree_sitter_swift(), name: "Swift") + case .rust: + config = try LanguageConfiguration(tree_sitter_rust(), name: "Rust") + case .javascript: + config = try LanguageConfiguration(tree_sitter_javascript(), name: "JavaScript") + case .typescript: + config = try Self.makeTypeScriptConfig() + case .tsx: + config = try Self.makeTSXConfig() + case .python: + config = try LanguageConfiguration(tree_sitter_python(), name: "Python") + case .json: + config = try LanguageConfiguration(tree_sitter_json(), name: "JSON") + case .shell: + config = try LanguageConfiguration(tree_sitter_bash(), name: "Bash") + case .html: + config = try LanguageConfiguration(tree_sitter_html(), name: "HTML") + case .css: + config = try LanguageConfiguration(tree_sitter_css(), name: "CSS") + case .yaml: + config = try LanguageConfiguration(tree_sitter_yaml(), name: "YAML") + case .toml: + config = try LanguageConfiguration(tree_sitter_toml(), name: "TOML") + case .markdown: + config = try LanguageConfiguration(tree_sitter_markdown(), name: "Markdown") + case .c: + return nil // TODO: Add tree-sitter-c SPM package (SPM resolution conflict) + case .cpp: + config = try LanguageConfiguration( + tree_sitter_cpp(), name: "C++", + bundleName: "TreeSitterCPP_TreeSitterCPP" + ) + case .go: + config = try LanguageConfiguration(tree_sitter_go(), name: "Go") + case .ruby: + config = try LanguageConfiguration(tree_sitter_ruby(), name: "Ruby") + case .php: + config = try LanguageConfiguration(tree_sitter_php(), name: "PHP") + case .java: + return nil // TODO: Add tree-sitter-java SPM package (Xcode conflicts with javascript) + case .csharp: + config = try LanguageConfiguration( + tree_sitter_c_sharp(), name: "C#", + bundleName: "TreeSitterCSharp_TreeSitterCSharp" + ) + case .scala: + config = try LanguageConfiguration(tree_sitter_scala(), name: "Scala") + case .haskell: + config = try LanguageConfiguration(tree_sitter_haskell(), name: "Haskell") + case .lua: + config = try LanguageConfiguration(tree_sitter_lua(), name: "Lua") + case .xml: + guard + let xmlBundle = Bundle.main.url( + forResource: "TreeSitterXML_TreeSitterXML", + withExtension: "bundle" + ) + else { return nil } + let xmlQueriesURL = xmlBundle.appendingPathComponent( + "Contents/Resources/xml", isDirectory: true + ) + config = try LanguageConfiguration( + tree_sitter_xml(), name: "XML", queriesURL: xmlQueriesURL + ) + case .plaintext: + return nil + } + } catch { + return nil + } + + if let config { languageConfigs[language] = config } + return config + } + + // MARK: - Language Injection Provider + + private static func languageConfigurationForInjection(_ name: String) -> LanguageConfiguration? { + switch name { + case "javascript": return languageConfiguration(for: .javascript) + case "css": return languageConfiguration(for: .css) + case "html": return languageConfiguration(for: .html) + case "json": return languageConfiguration(for: .json) + case "bash": return languageConfiguration(for: .shell) + case "python": return languageConfiguration(for: .python) + case "typescript": return languageConfiguration(for: .typescript) + case "yaml": return languageConfiguration(for: .yaml) + case "toml": return languageConfiguration(for: .toml) + case "markdown_inline": + let lang = Language(tree_sitter_markdown_inline()) + return try? LanguageConfiguration( + lang, name: "MarkdownInline", + bundleName: "TreeSitterMarkdown_TreeSitterMarkdownInline" + ) + case "regex", "jsdoc": return nil + default: return nil + } + } + + // MARK: - TypeScript / TSX Query Merging + + private static func makeTypeScriptConfig() throws -> LanguageConfiguration { + let lang = Language(tree_sitter_typescript()) + let queries = try mergedQueries( + language: lang, + baseBundleName: "TreeSitterJavaScript_TreeSitterJavaScript", + overlayBundleName: "TreeSitterTypeScript_TreeSitterTypeScript" ) + return LanguageConfiguration(lang, name: "TypeScript", queries: queries) + } + + private static func makeTSXConfig() throws -> LanguageConfiguration { + let lang = Language(tree_sitter_tsx()) + let queries = try mergedQueries( + language: lang, + baseBundleName: "TreeSitterJavaScript_TreeSitterJavaScript", + overlayBundleName: "TreeSitterTypeScript_TreeSitterTypeScript", + extraBaseHighlights: ["highlights-jsx"] + ) + return LanguageConfiguration(lang, name: "TSX", queries: queries) + } + + private static func mergedQueries( + language: Language, + baseBundleName: String, + overlayBundleName: String, + extraBaseHighlights: [String] = [] + ) throws -> [Query.Definition: Query] { + guard let baseURL = queriesDirectoryURL(for: baseBundleName), + let overlayURL = queriesDirectoryURL(for: overlayBundleName) + else { + throw MergedQueryError.bundleNotFound + } + + var queries = [Query.Definition: Query]() + + for definition: Query.Definition in [.highlights, .locals, .injections] { + var combined = Data() + + // Load base query file + let baseFile = baseURL.appendingPathComponent(definition.filename) + if let data = try? Data(contentsOf: baseFile) { + combined.append(data) + combined.append(Data("\n".utf8)) + } + + // Load extra base files (e.g., highlights-jsx.scm for TSX) + if definition == .highlights { + for extra in extraBaseHighlights { + let extraFile = baseURL.appendingPathComponent("\(extra).scm") + if let data = try? Data(contentsOf: extraFile) { + combined.append(data) + combined.append(Data("\n".utf8)) + } + } + } + + // Load overlay query file + let overlayFile = overlayURL.appendingPathComponent(definition.filename) + if let data = try? Data(contentsOf: overlayFile) { + combined.append(data) + } + + if !combined.isEmpty { + queries[definition] = try Query(language: language, data: combined) + } + } + + return queries + } + + private static func queriesDirectoryURL(for bundleName: String) -> URL? { + guard let bundlePath = Bundle.main.url(forResource: bundleName, withExtension: "bundle") else { return nil } + let short = bundlePath.appendingPathComponent("queries", isDirectory: true) + if FileManager.default.fileExists(atPath: short.path) { return short } + return bundlePath.appendingPathComponent("Contents/Resources/queries", isDirectory: true) + } + + private enum MergedQueryError: Error { + case bundleNotFound + } + + // MARK: - Token Attribute Provider + + private static let attributeProvider: TokenAttributeProvider = { token in + let name = token.name + let color: NSColor + let font: NSFont + + if name.hasPrefix("keyword") { + color = EditorTheme.keyword + font = boldFont + } else if name.hasPrefix("string") { + color = EditorTheme.string + font = regularFont + } else if name.hasPrefix("comment") { + color = EditorTheme.comment + font = regularFont + } else if name.hasPrefix("number") || name.hasPrefix("float") || name.hasPrefix("integer") { + color = EditorTheme.number + font = regularFont + } else if name.hasPrefix("type") || name.hasPrefix("constructor") { + color = EditorTheme.type + font = regularFont + } else if name.hasPrefix("function") || name.hasPrefix("method") { + color = EditorTheme.function + font = regularFont + } else if name.hasPrefix("property") || name.hasPrefix("field") { + color = EditorTheme.property + font = regularFont + } else if name.hasPrefix("tag") { + color = EditorTheme.keyword + font = boldFont + } else if name.hasPrefix("attribute") || name.hasPrefix("include") || name.hasPrefix("preproc") { + color = EditorTheme.preprocessor + font = regularFont + } else if name.hasPrefix("variable") || name.hasPrefix("constant") { + color = EditorTheme.type + font = regularFont + } else if name.hasPrefix("label") { + color = EditorTheme.property + font = regularFont + } else if name.hasPrefix("operator") || name.hasPrefix("punctuation") { + color = .secondaryLabelColor + font = regularFont + } else if name.hasPrefix("text.title") { + color = EditorTheme.keyword + font = boldFont + } else if name.hasPrefix("text.literal") { + color = EditorTheme.string + font = regularFont + } else if name.hasPrefix("text.uri") { + color = EditorTheme.function + font = regularFont + } else if name.hasPrefix("text.reference") { + color = EditorTheme.type + font = regularFont + } else if name.hasPrefix("text.emphasis") { + color = .labelColor + font = italicFont + } else if name.hasPrefix("text.strong") { + color = .labelColor + font = boldFont + } else if name.hasPrefix("text") { + color = .labelColor + font = regularFont + } else { + color = .labelColor + font = regularFont + } + + return [.font: font, .foregroundColor: color] } } diff --git a/apps/purepoint-macos/purepoint-macos/State/DiffState.swift b/apps/purepoint-macos/purepoint-macos/State/DiffState.swift index cee2bed..2edc276 100644 --- a/apps/purepoint-macos/purepoint-macos/State/DiffState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/DiffState.swift @@ -23,7 +23,8 @@ final class DiffState { private var watcher: WorktreeWatcher? private var currentWorktreePath: String? private var currentProjectRoot: String? - private var loadTask: Task? + private var unstagedTask: Task? + private var prTask: Task? // MARK: - Load for Worktree @@ -33,12 +34,10 @@ final class DiffState { currentWorktreePath = path currentProjectRoot = nil - loadTask?.cancel() - loadTask = Task { - await fetchUnstaged(path: path) - guard !Task.isCancelled else { return } - await fetchPRs(cwd: path, branch: branch) - } + unstagedTask?.cancel() + prTask?.cancel() + unstagedTask = Task { await fetchUnstaged(path: path) } + prTask = Task { await fetchPRs(cwd: path, branch: branch) } startWatching(path: path) } @@ -49,12 +48,10 @@ final class DiffState { currentProjectRoot = projectRoot currentWorktreePath = nil - loadTask?.cancel() - loadTask = Task { - await fetchUnstaged(path: projectRoot) - guard !Task.isCancelled else { return } - await fetchPRs(cwd: projectRoot, branch: nil) - } + unstagedTask?.cancel() + prTask?.cancel() + unstagedTask = Task { await fetchUnstaged(path: projectRoot) } + prTask = Task { await fetchPRs(cwd: projectRoot, branch: nil) } startWatching(path: projectRoot) } @@ -80,19 +77,11 @@ final class DiffState { // MARK: - Refresh func refresh() { - if let path = currentWorktreePath { - loadTask?.cancel() - loadTask = Task { - await fetchUnstaged(path: path) - } - } else if let root = currentProjectRoot { - loadTask?.cancel() - loadTask = Task { - await fetchUnstaged(path: root) - guard !Task.isCancelled else { return } - await fetchPRs(cwd: root, branch: nil) - } - } + let path = currentWorktreePath ?? currentProjectRoot + guard let path else { return } + unstagedTask?.cancel() + unstagedTask = Task { await fetchUnstaged(path: path) } + // Don't re-fetch PRs on refresh — they don't change on file save } // MARK: - Watcher @@ -110,7 +99,8 @@ final class DiffState { func stopWatching() { watcher?.stop() watcher = nil - loadTask?.cancel() + unstagedTask?.cancel() + prTask?.cancel() } // MARK: - Private diff --git a/apps/purepoint-macos/purepoint-macos/State/EditorState.swift b/apps/purepoint-macos/purepoint-macos/State/EditorState.swift index 9fdad4b..28222de 100644 --- a/apps/purepoint-macos/purepoint-macos/State/EditorState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/EditorState.swift @@ -4,24 +4,16 @@ import Observation @Observable @MainActor final class EditorState { - var openTabs: [EditorTab] = [] - var activeTabId: String? - var showChangesTab: Bool = true + var currentFile: EditorTab? + var showChanges: Bool = true var externalChangeAlert: String? private var fileWatcher: FileTreeWatcher? - private var watchedFilePaths: Set = [] - - var activeTab: EditorTab? { - guard let id = activeTabId else { return nil } - return openTabs.first { $0.id == id } - } + private var watchedPath: String? func openFile(path: String, name: String) { - // If already open, just activate - if openTabs.contains(where: { $0.id == path }) { - activeTabId = path - showChangesTab = false + if currentFile?.id == path { + showChanges = false return } @@ -31,7 +23,7 @@ final class EditorState { let modDate = FileIOService.fileModificationDate(at: path) let language = EditorLanguage.detect(from: name) - let tab = EditorTab( + currentFile = EditorTab( id: path, name: name, content: result.content, @@ -40,9 +32,7 @@ final class EditorState { isBinary: result.isBinary, language: language ) - openTabs.append(tab) - activeTabId = path - showChangesTab = false + showChanges = false watchFile(path: path) } catch { // Silently ignore — file may have been deleted @@ -50,55 +40,27 @@ final class EditorState { } } - func closeTab(id: String) { - openTabs.removeAll { $0.id == id } - unwatchFile(path: id) - - if activeTabId == id { - activeTabId = openTabs.last?.id - if activeTabId == nil { - showChangesTab = true - } - } - } - - func setActiveTab(id: String) { - activeTabId = id - showChangesTab = false - } - - func activateChangesTab() { - activeTabId = nil - showChangesTab = true - } - - func markDirty(id: String) { - guard let idx = openTabs.firstIndex(where: { $0.id == id }) else { return } - openTabs[idx].isDirty = true + func updateContent(content: String) { + currentFile?.content = content + currentFile?.isDirty = true } - func updateContent(id: String, content: String) { - guard let idx = openTabs.firstIndex(where: { $0.id == id }) else { return } - openTabs[idx].content = content - openTabs[idx].isDirty = true + func saveFile() async throws { + guard let file = currentFile else { return } + try await FileIOService.writeFile(content: file.content, to: file.id) + currentFile?.isDirty = false + currentFile?.lastModified = FileIOService.fileModificationDate(at: file.id) } - func saveFile(id: String) async throws { - guard let idx = openTabs.firstIndex(where: { $0.id == id }) else { return } - try await FileIOService.writeFile(content: openTabs[idx].content, to: id) - openTabs[idx].isDirty = false - openTabs[idx].lastModified = FileIOService.fileModificationDate(at: id) - } - - func reloadFile(id: String) { - guard openTabs.contains(where: { $0.id == id }) else { return } + func reloadFile() { + guard let file = currentFile else { return } + let path = file.id Task { do { - let result = try await FileIOService.readFile(at: id) - guard let idx = openTabs.firstIndex(where: { $0.id == id }) else { return } - openTabs[idx].content = result.content - openTabs[idx].isDirty = false - openTabs[idx].lastModified = FileIOService.fileModificationDate(at: id) + let result = try await FileIOService.readFile(at: path) + currentFile?.content = result.content + currentFile?.isDirty = false + currentFile?.lastModified = FileIOService.fileModificationDate(at: path) } catch {} } externalChangeAlert = nil @@ -111,7 +73,7 @@ final class EditorState { func stopWatching() { fileWatcher?.stopAll() fileWatcher = nil - watchedFilePaths.removeAll() + watchedPath = nil } // MARK: - File Watching @@ -127,45 +89,34 @@ final class EditorState { } private func watchFile(path: String) { - ensureWatcher() let dir = (path as NSString).deletingLastPathComponent - if !watchedFilePaths.contains(dir) { - watchedFilePaths.insert(dir) + if watchedPath != dir { + if let old = watchedPath { + fileWatcher?.unwatchDirectory(path: old) + } + ensureWatcher() + watchedPath = dir fileWatcher?.watchDirectory(path: dir) } } - private func unwatchFile(path: String) { - let dir = (path as NSString).deletingLastPathComponent - let hasOtherFilesInDir = openTabs.contains { tab in - tab.id != path && (tab.id as NSString).deletingLastPathComponent == dir - } - if !hasOtherFilesInDir { - watchedFilePaths.remove(dir) - fileWatcher?.unwatchDirectory(path: dir) - } - } - private func checkForExternalChanges() { - for tab in openTabs { - guard let lastMod = tab.lastModified, - let currentMod = FileIOService.fileModificationDate(at: tab.id), - currentMod > lastMod - else { continue } - - if tab.isDirty { - externalChangeAlert = tab.id - } else { - let tabId = tab.id - Task { - do { - let result = try await FileIOService.readFile(at: tabId) - guard let idx = openTabs.firstIndex(where: { $0.id == tabId }) - else { return } - openTabs[idx].content = result.content - openTabs[idx].lastModified = currentMod - } catch {} - } + guard let file = currentFile, + let lastMod = file.lastModified, + let currentMod = FileIOService.fileModificationDate(at: file.id), + currentMod > lastMod + else { return } + + if file.isDirty { + externalChangeAlert = file.id + } else { + let filePath = file.id + Task { + do { + let result = try await FileIOService.readFile(at: filePath) + currentFile?.content = result.content + currentFile?.lastModified = currentMod + } catch {} } } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Detail/ProjectDetailView.swift b/apps/purepoint-macos/purepoint-macos/Views/Detail/ProjectDetailView.swift index ae1c0fb..00d5e87 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Detail/ProjectDetailView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Detail/ProjectDetailView.swift @@ -3,21 +3,52 @@ import SwiftUI struct ProjectDetailView: View { let project: ProjectState @State private var diffState = DiffState() + @State private var fileTreeState = FileTreeState() + @State private var editorState = EditorState() + @State private var showFileTree = true + @State private var sidebarRatio: CGFloat = 0.22 + @State private var saveError: String? var body: some View { VStack(spacing: 0) { header Divider() - tabBar - Divider() - content + DraggableSplit( + axis: .vertical, + ratio: showFileTree ? sidebarRatio : 0, + onRatioChanged: { sidebarRatio = $0 } + ) { + FileTreeSidebarView( + fileTreeState: fileTreeState, + showFileTree: $showFileTree, + onFileSelected: { path, name in + editorState.openFile(path: path, name: name) + } + ) + } second: { + VStack(spacing: 0) { + if let file = editorState.currentFile, !editorState.showChanges { + fileBreadcrumb(file) + } + Divider() + editorContent + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + externalChangeBanner + } .task(id: project.projectRoot) { + editorState.stopWatching() + editorState = EditorState() diffState.loadForProject(projectRoot: project.projectRoot) + fileTreeState.load(worktreePath: project.projectRoot) } .onDisappear { diffState.stopWatching() + fileTreeState.stopWatching() + editorState.stopWatching() } } @@ -25,6 +56,18 @@ struct ProjectDetailView: View { private var header: some View { HStack(spacing: 8) { + if !showFileTree { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showFileTree = true + } + } label: { + Image(systemName: "sidebar.left") + } + .buttonStyle(.plain) + .help("Show file tree") + } + Image(systemName: "folder.fill") .font(.system(size: 14)) .foregroundStyle(.secondary) @@ -38,8 +81,19 @@ struct ProjectDetailView: View { Spacer() + Button { + editorState.showChanges.toggle() + } label: { + Image(systemName: "doc.badge.plus") + .font(.system(size: 12)) + .foregroundStyle(editorState.showChanges ? .primary : .secondary) + } + .buttonStyle(.borderless) + .help("Toggle changes view") + Button { diffState.refresh() + fileTreeState.refresh() } label: { Image(systemName: "arrow.clockwise") .font(.system(size: 12)) @@ -51,9 +105,74 @@ struct ProjectDetailView: View { .padding(.vertical, 10) } - // MARK: - Tab Bar + // MARK: - File Breadcrumb + + private func fileBreadcrumb(_ file: EditorTab) -> some View { + HStack(spacing: 6) { + Image(systemName: file.language.icon) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Text(file.name) + .font(.system(size: 11)) + .lineLimit(1) + if file.isDirty { + Circle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 6, height: 6) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color(nsColor: Theme.cardHeaderBackground)) + } + + // MARK: - Editor Content + + @ViewBuilder + private var editorContent: some View { + if editorState.showChanges { + changesContent + } else if let file = editorState.currentFile { + if file.isBinary { + binaryPlaceholder(file) + } else { + EditorContentRepresentable( + content: file.content, + language: file.language, + isBinary: false, + isEditable: true, + onContentChanged: { newContent in + editorState.updateContent(content: newContent) + }, + onSave: { + Task { + do { + try await editorState.saveFile() + saveError = nil + } catch { + saveError = "Failed to save \(file.name): \(error.localizedDescription)" + } + } + } + ) + } + } else { + editorPlaceholder + } + } - private var tabBar: some View { + // MARK: - Changes Content + + private var changesContent: some View { + VStack(spacing: 0) { + diffTabBar + Divider() + diffContent + } + } + + private var diffTabBar: some View { Picker("", selection: $diffState.activeTab) { Text("Unstaged Changes") .tag(DiffTab.unstaged) @@ -65,10 +184,8 @@ struct ProjectDetailView: View { .padding(.vertical, 8) } - // MARK: - Content - @ViewBuilder - private var content: some View { + private var diffContent: some View { switch diffState.activeTab { case .unstaged: DiffListView( @@ -97,7 +214,7 @@ struct ProjectDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if diffState.pullRequests.isEmpty { VStack(spacing: 12) { - Image(systemName: "pull.request") + Image(systemName: "arrow.triangle.pull") .font(.system(size: 28)) .foregroundStyle(.secondary) Text("No open pull requests") @@ -106,10 +223,8 @@ struct ProjectDetailView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if diffState.selectedPR == nil { - // Show PR list for selection prListView } else { - // Show selected PR with back button + diff selectedPRView } } @@ -133,7 +248,6 @@ struct ProjectDetailView: View { private var selectedPRView: some View { VStack(spacing: 0) { - // Back bar with PR info HStack(spacing: 8) { Button { diffState.selectedPR = nil @@ -178,4 +292,88 @@ struct ProjectDetailView: View { } } + // MARK: - Placeholders + + private var editorPlaceholder: some View { + VStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + Text("Select a file to edit") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func binaryPlaceholder(_ tab: EditorTab) -> some View { + VStack(spacing: 12) { + Image(systemName: "doc.fill") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + Text("Binary file") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Text(tab.name) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Banners + + @ViewBuilder + private var externalChangeBanner: some View { + if editorState.externalChangeAlert != nil, let file = editorState.currentFile { + bannerView( + icon: "exclamationmark.triangle.fill", + iconColor: .yellow, + message: "\(file.name) changed on disk." + ) { + Button("Reload") { + editorState.reloadFile() + } + .buttonStyle(.bordered) + .controlSize(.small) + Button("Dismiss") { + editorState.dismissExternalChange() + } + .buttonStyle(.plain) + .font(.system(size: 11)) + } + } + + if let error = saveError { + bannerView( + icon: "xmark.circle.fill", + iconColor: .red, + message: error + ) { + Button("Dismiss") { + saveError = nil + } + .buttonStyle(.plain) + .font(.system(size: 11)) + } + } + } + + private func bannerView( + icon: String, + iconColor: Color, + message: String, + @ViewBuilder actions: () -> Actions + ) -> some View { + HStack { + Image(systemName: icon) + .foregroundStyle(iconColor) + Text(message) + .font(.system(size: 12)) + actions() + } + .padding(8) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(8) + } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Detail/WorktreeDetailView.swift b/apps/purepoint-macos/purepoint-macos/Views/Detail/WorktreeDetailView.swift index f7b7292..a09c36b 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Detail/WorktreeDetailView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Detail/WorktreeDetailView.swift @@ -27,12 +27,9 @@ struct WorktreeDetailView: View { ) } second: { VStack(spacing: 0) { - EditorTabBar( - editorState: editorState, - onCloseTab: { id in - editorState.closeTab(id: id) - } - ) + if let file = editorState.currentFile, !editorState.showChanges { + fileBreadcrumb(file) + } Divider() editorContent } @@ -88,6 +85,16 @@ struct WorktreeDetailView: View { Spacer() + Button { + editorState.showChanges.toggle() + } label: { + Image(systemName: "doc.badge.plus") + .font(.system(size: 12)) + .foregroundStyle(editorState.showChanges ? .primary : .secondary) + } + .buttonStyle(.borderless) + .help("Toggle changes view") + Button { diffState.refresh() fileTreeState.refresh() @@ -102,30 +109,53 @@ struct WorktreeDetailView: View { .padding(.vertical, 10) } + // MARK: - File Breadcrumb + + private func fileBreadcrumb(_ file: EditorTab) -> some View { + HStack(spacing: 6) { + Image(systemName: file.language.icon) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Text(file.name) + .font(.system(size: 11)) + .lineLimit(1) + if file.isDirty { + Circle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 6, height: 6) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color(nsColor: Theme.cardHeaderBackground)) + } + // MARK: - Editor Content @ViewBuilder private var editorContent: some View { - if editorState.showChangesTab { + if editorState.showChanges { changesContent - } else if let tab = editorState.activeTab { - if tab.isBinary { - binaryPlaceholder(tab) + } else if let file = editorState.currentFile { + if file.isBinary { + binaryPlaceholder(file) } else { EditorContentRepresentable( - content: tab.content, + content: file.content, + language: file.language, isBinary: false, isEditable: true, onContentChanged: { newContent in - editorState.updateContent(id: tab.id, content: newContent) + editorState.updateContent(content: newContent) }, onSave: { Task { do { - try await editorState.saveFile(id: tab.id) + try await editorState.saveFile() saveError = nil } catch { - saveError = "Failed to save \(tab.name): \(error.localizedDescription)" + saveError = "Failed to save \(file.name): \(error.localizedDescription)" } } } @@ -178,7 +208,7 @@ struct WorktreeDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if diffState.pullRequests.isEmpty { VStack(spacing: 12) { - Image(systemName: "pull.request") + Image(systemName: "arrow.triangle.pull") .font(.system(size: 28)) .foregroundStyle(.secondary) Text("No open pull requests for this branch") @@ -260,16 +290,14 @@ struct WorktreeDetailView: View { @ViewBuilder private var externalChangeBanner: some View { - if let changedPath = editorState.externalChangeAlert, - let tab = editorState.openTabs.first(where: { $0.id == changedPath }) - { + if editorState.externalChangeAlert != nil, let file = editorState.currentFile { bannerView( icon: "exclamationmark.triangle.fill", iconColor: .yellow, - message: "\(tab.name) changed on disk." + message: "\(file.name) changed on disk." ) { Button("Reload") { - editorState.reloadFile(id: changedPath) + editorState.reloadFile() } .buttonStyle(.bordered) .controlSize(.small) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorNSView.swift b/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorNSView.swift index 2f43d1c..bc3d85f 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorNSView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorNSView.swift @@ -7,6 +7,7 @@ class EditorNSView: NSView { private let scrollView = NSScrollView() private let textView: EditorTextView private var lineRuler: LineNumberRulerView? + private var highlightManager: SyntaxHighlightManager? private var suppressCallbacks = false var onContentChanged: ((String) -> Void)? @@ -65,12 +66,19 @@ class EditorNSView: NSView { textView.onSave = { [weak self] in self?.onSave?() } + + highlightManager = SyntaxHighlightManager(textView: textView) + } + + func setLanguage(_ language: EditorLanguage) { + highlightManager?.setLanguage(language) } func setContent(_ content: String) { suppressCallbacks = true textView.string = content suppressCallbacks = false + lineRuler?.rebuildLineStarts() lineRuler?.needsDisplay = true } @@ -85,6 +93,7 @@ class EditorNSView: NSView { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() textView.backgroundColor = Theme.cardBackground + highlightManager?.invalidate() } } @@ -92,6 +101,7 @@ class EditorNSView: NSView { struct EditorContentRepresentable: NSViewRepresentable { let content: String + let language: EditorLanguage let isBinary: Bool let isEditable: Bool var onContentChanged: ((String) -> Void)? @@ -103,6 +113,7 @@ struct EditorContentRepresentable: NSViewRepresentable { view.onSave = onSave view.setEditable(isEditable && !isBinary) view.setContent(isBinary ? "" : content) + view.setLanguage(language) return view } @@ -115,5 +126,7 @@ struct EditorContentRepresentable: NSViewRepresentable { if !isBinary && nsView.getContent() != content { nsView.setContent(content) } + + nsView.setLanguage(language) } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorTabBar.swift b/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorTabBar.swift deleted file mode 100644 index 5ffbc8b..0000000 --- a/apps/purepoint-macos/purepoint-macos/Views/Editor/EditorTabBar.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SwiftUI - -struct EditorTabBar: View { - var editorState: EditorState - var onCloseTab: (String) -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - changesTab - ForEach(editorState.openTabs) { tab in - fileTab(tab) - } - } - } - .frame(height: 32) - .clipped() - .background(Color(nsColor: Theme.cardHeaderBackground)) - } - - // MARK: - Changes Tab - - private var changesTab: some View { - let isActive = editorState.showChangesTab - - return Button { - editorState.activateChangesTab() - } label: { - HStack(spacing: 4) { - Image(systemName: "doc.badge.plus") - .font(.system(size: 10)) - Text("Changes") - .font(.system(size: 11)) - } - .padding(.horizontal, 12) - .frame(height: 32) - .background(isActive ? Color(nsColor: Theme.cardBackground) : Color.clear) - .overlay(alignment: .bottom) { - if isActive { - Rectangle() - .fill(Color.accentColor) - .frame(height: 2) - } - } - } - .buttonStyle(.plain) - } - - // MARK: - File Tab - - private func fileTab(_ tab: EditorTab) -> some View { - FileTabButton( - tab: tab, - isActive: editorState.activeTabId == tab.id, - onSelect: { editorState.setActiveTab(id: tab.id) }, - onClose: { onCloseTab(tab.id) } - ) - } -} - -private struct FileTabButton: View { - let tab: EditorTab - let isActive: Bool - var onSelect: () -> Void - var onClose: () -> Void - @State private var isHovered = false - - var body: some View { - HStack(spacing: 4) { - Image(systemName: tab.language.icon) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - - Text(tab.name) - .font(.system(size: 11)) - .lineLimit(1) - - if tab.isDirty { - Circle() - .fill(Color.primary.opacity(0.4)) - .frame(width: 6, height: 6) - } - - if isActive || isHovered { - Button { - onClose() - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .frame(height: 32) - .contentShape(Rectangle()) - .onTapGesture(perform: onSelect) - .background(isActive ? Color(nsColor: Theme.cardBackground) : Color.clear) - .overlay(alignment: .bottom) { - if isActive { - Rectangle() - .fill(Color.accentColor) - .frame(height: 2) - } - } - .onHover { isHovered = $0 } - } -} diff --git a/apps/purepoint-macos/purepoint-macos/Views/Editor/LineNumberRulerView.swift b/apps/purepoint-macos/purepoint-macos/Views/Editor/LineNumberRulerView.swift index d9cbe0f..e0a129e 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Editor/LineNumberRulerView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Editor/LineNumberRulerView.swift @@ -5,6 +5,7 @@ class LineNumberRulerView: NSRulerView { private let textColor = NSColor.tertiaryLabelColor private let gutterWidth: CGFloat = 40 private var boundsObserver: NSObjectProtocol? + private var lineStarts: [Int] = [0] init(textView: NSTextView) { guard let scrollView = textView.enclosingScrollView else { @@ -31,6 +32,8 @@ class LineNumberRulerView: NSRulerView { name: NSText.didChangeNotification, object: textView ) + + rebuildLineStarts() } required init(coder: NSCoder) { @@ -45,9 +48,32 @@ class LineNumberRulerView: NSRulerView { } @objc private func textDidChange(_ notification: Notification) { + rebuildLineStarts() needsDisplay = true } + func rebuildLineStarts() { + guard let textView = clientView as? NSTextView else { return } + let s = textView.string as NSString + lineStarts = [0] + var idx = 0 + while idx < s.length { + let range = s.lineRange(for: NSRange(location: idx, length: 0)) + let next = NSMaxRange(range) + if next < s.length { lineStarts.append(next) } + idx = next + } + } + + private func lineIndex(forCharacter charIndex: Int) -> Int { + var lo = 0, hi = lineStarts.count + while lo < hi { + let mid = (lo + hi) / 2 + if lineStarts[mid] <= charIndex { lo = mid + 1 } else { hi = mid } + } + return lo - 1 + } + override func drawHashMarksAndLabels(in rect: NSRect) { guard let textView = clientView as? NSTextView, let layoutManager = textView.layoutManager, @@ -55,40 +81,42 @@ class LineNumberRulerView: NSRulerView { else { return } let visibleRect = scrollView?.contentView.bounds ?? rect - let visibleGlyphRange = layoutManager.glyphRange( - forBoundingRect: visibleRect, - in: textContainer - ) - let visibleCharRange = layoutManager.characterRange( - forGlyphRange: visibleGlyphRange, - actualGlyphRange: nil - ) - - let content = textView.string as NSString let containerOrigin = textView.textContainerOrigin + + // Ensure layout for the visible region + let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: textContainer) + if glyphRange.length == 0 { + let attrs: [NSAttributedString.Key: Any] = [ + .font: lineNumberFont, + .foregroundColor: textColor, + ] + let numStr = "1" as NSString + let strSize = numStr.size(withAttributes: attrs) + let drawPoint = NSPoint( + x: gutterWidth - strSize.width - 6, + y: containerOrigin.y + ) + numStr.draw(at: drawPoint, withAttributes: attrs) + return + } + let visibleCharRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + let firstLine = lineIndex(forCharacter: visibleCharRange.location) + let attrs: [NSAttributedString.Key: Any] = [ .font: lineNumberFont, .foregroundColor: textColor, ] - var lineNumber = 1 - // Count lines before visible range - var idx = 0 - while idx < visibleCharRange.location { - let lineRange = content.lineRange(for: NSRange(location: idx, length: 0)) - lineNumber += 1 - idx = NSMaxRange(lineRange) - } + for i in firstLine..= NSMaxRange(visibleCharRange) { break } - // Draw visible line numbers - idx = visibleCharRange.location - let endIdx = NSMaxRange(visibleCharRange) - while idx < endIdx { - let lineRange = content.lineRange(for: NSRange(location: idx, length: 0)) - let glyphRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) - var lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) + let glyphIdx = layoutManager.glyphIndexForCharacter(at: charIdx) + var lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIdx, effectiveRange: nil) lineRect.origin.y += containerOrigin.y + let lineNumber = i + 1 let numStr = "\(lineNumber)" as NSString let strSize = numStr.size(withAttributes: attrs) let drawPoint = NSPoint( @@ -96,9 +124,6 @@ class LineNumberRulerView: NSRulerView { y: lineRect.origin.y + (lineRect.height - strSize.height) / 2 ) numStr.draw(at: drawPoint, withAttributes: attrs) - - lineNumber += 1 - idx = NSMaxRange(lineRange) } } }