├── .gitignore ├── LICENSE ├── Promptor.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ └── Promptor.xcscheme ├── Promptor ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── promptor-icon-trim-v4.png │ └── Contents.json ├── ContentView.swift ├── FileAggregator.swift ├── FolderWatcher.swift ├── ImportOptionsSheet.swift ├── Models.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── PromptToolbar.swift ├── Promptor.entitlements ├── PromptorApp.swift └── TemplatePickerSheet.swift ├── README.md └── docs └── screenshots └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # User-specific Xcode files 5 | *.xcuserdatad 6 | *.xcworkspace/contents.xcworkspacedata 7 | xcuserdata/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Edrick Da Corte Henriquez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Promptor.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | 0930F1A62DC3B64000F50558 /* Promptor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Promptor.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 14 | 0930F1A82DC3B64000F50558 /* Promptor */ = { 15 | isa = PBXFileSystemSynchronizedRootGroup; 16 | path = Promptor; 17 | sourceTree = ""; 18 | }; 19 | /* End PBXFileSystemSynchronizedRootGroup section */ 20 | 21 | /* Begin PBXFrameworksBuildPhase section */ 22 | 0930F1A32DC3B64000F50558 /* Frameworks */ = { 23 | isa = PBXFrameworksBuildPhase; 24 | buildActionMask = 2147483647; 25 | files = ( 26 | ); 27 | runOnlyForDeploymentPostprocessing = 0; 28 | }; 29 | /* End PBXFrameworksBuildPhase section */ 30 | 31 | /* Begin PBXGroup section */ 32 | 0930F19D2DC3B64000F50558 = { 33 | isa = PBXGroup; 34 | children = ( 35 | 0930F1A82DC3B64000F50558 /* Promptor */, 36 | 0930F1A72DC3B64000F50558 /* Products */, 37 | ); 38 | sourceTree = ""; 39 | }; 40 | 0930F1A72DC3B64000F50558 /* Products */ = { 41 | isa = PBXGroup; 42 | children = ( 43 | 0930F1A62DC3B64000F50558 /* Promptor.app */, 44 | ); 45 | name = Products; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXGroup section */ 49 | 50 | /* Begin PBXNativeTarget section */ 51 | 0930F1A52DC3B64000F50558 /* Promptor */ = { 52 | isa = PBXNativeTarget; 53 | buildConfigurationList = 0930F1B52DC3B64100F50558 /* Build configuration list for PBXNativeTarget "Promptor" */; 54 | buildPhases = ( 55 | 0930F1A22DC3B64000F50558 /* Sources */, 56 | 0930F1A32DC3B64000F50558 /* Frameworks */, 57 | 0930F1A42DC3B64000F50558 /* Resources */, 58 | ); 59 | buildRules = ( 60 | ); 61 | dependencies = ( 62 | ); 63 | fileSystemSynchronizedGroups = ( 64 | 0930F1A82DC3B64000F50558 /* Promptor */, 65 | ); 66 | name = Promptor; 67 | packageProductDependencies = ( 68 | ); 69 | productName = Promptor; 70 | productReference = 0930F1A62DC3B64000F50558 /* Promptor.app */; 71 | productType = "com.apple.product-type.application"; 72 | }; 73 | /* End PBXNativeTarget section */ 74 | 75 | /* Begin PBXProject section */ 76 | 0930F19E2DC3B64000F50558 /* Project object */ = { 77 | isa = PBXProject; 78 | attributes = { 79 | BuildIndependentTargetsInParallel = 1; 80 | LastSwiftUpdateCheck = 1610; 81 | LastUpgradeCheck = 1610; 82 | TargetAttributes = { 83 | 0930F1A52DC3B64000F50558 = { 84 | CreatedOnToolsVersion = 16.1; 85 | }; 86 | }; 87 | }; 88 | buildConfigurationList = 0930F1A12DC3B64000F50558 /* Build configuration list for PBXProject "Promptor" */; 89 | developmentRegion = en; 90 | hasScannedForEncodings = 0; 91 | knownRegions = ( 92 | en, 93 | Base, 94 | ); 95 | mainGroup = 0930F19D2DC3B64000F50558; 96 | minimizedProjectReferenceProxies = 1; 97 | preferredProjectObjectVersion = 77; 98 | productRefGroup = 0930F1A72DC3B64000F50558 /* Products */; 99 | projectDirPath = ""; 100 | projectRoot = ""; 101 | targets = ( 102 | 0930F1A52DC3B64000F50558 /* Promptor */, 103 | ); 104 | }; 105 | /* End PBXProject section */ 106 | 107 | /* Begin PBXResourcesBuildPhase section */ 108 | 0930F1A42DC3B64000F50558 /* Resources */ = { 109 | isa = PBXResourcesBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | /* End PBXResourcesBuildPhase section */ 116 | 117 | /* Begin PBXSourcesBuildPhase section */ 118 | 0930F1A22DC3B64000F50558 /* Sources */ = { 119 | isa = PBXSourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXSourcesBuildPhase section */ 126 | 127 | /* Begin XCBuildConfiguration section */ 128 | 0930F1B32DC3B64100F50558 /* Debug */ = { 129 | isa = XCBuildConfiguration; 130 | buildSettings = { 131 | ALWAYS_SEARCH_USER_PATHS = NO; 132 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 133 | CLANG_ANALYZER_NONNULL = YES; 134 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 135 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 136 | CLANG_ENABLE_MODULES = YES; 137 | CLANG_ENABLE_OBJC_ARC = YES; 138 | CLANG_ENABLE_OBJC_WEAK = YES; 139 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 140 | CLANG_WARN_BOOL_CONVERSION = YES; 141 | CLANG_WARN_COMMA = YES; 142 | CLANG_WARN_CONSTANT_CONVERSION = YES; 143 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 144 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 145 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 146 | CLANG_WARN_EMPTY_BODY = YES; 147 | CLANG_WARN_ENUM_CONVERSION = YES; 148 | CLANG_WARN_INFINITE_RECURSION = YES; 149 | CLANG_WARN_INT_CONVERSION = YES; 150 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 151 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 152 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 153 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 154 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 155 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 156 | CLANG_WARN_STRICT_PROTOTYPES = YES; 157 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 158 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 159 | CLANG_WARN_UNREACHABLE_CODE = YES; 160 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 161 | COPY_PHASE_STRIP = NO; 162 | DEBUG_INFORMATION_FORMAT = dwarf; 163 | ENABLE_STRICT_OBJC_MSGSEND = YES; 164 | ENABLE_TESTABILITY = YES; 165 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu17; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 181 | MACOSX_DEPLOYMENT_TARGET = 13.4; 182 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 183 | MTL_FAST_MATH = YES; 184 | ONLY_ACTIVE_ARCH = YES; 185 | SDKROOT = macosx; 186 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 187 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 188 | }; 189 | name = Debug; 190 | }; 191 | 0930F1B42DC3B64100F50558 /* Release */ = { 192 | isa = XCBuildConfiguration; 193 | buildSettings = { 194 | ALWAYS_SEARCH_USER_PATHS = NO; 195 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 196 | CLANG_ANALYZER_NONNULL = YES; 197 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 198 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 218 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 219 | CLANG_WARN_STRICT_PROTOTYPES = YES; 220 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 221 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 222 | CLANG_WARN_UNREACHABLE_CODE = YES; 223 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 224 | COPY_PHASE_STRIP = NO; 225 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 226 | ENABLE_NS_ASSERTIONS = NO; 227 | ENABLE_STRICT_OBJC_MSGSEND = YES; 228 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 229 | GCC_C_LANGUAGE_STANDARD = gnu17; 230 | GCC_NO_COMMON_BLOCKS = YES; 231 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 232 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 233 | GCC_WARN_UNDECLARED_SELECTOR = YES; 234 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 235 | GCC_WARN_UNUSED_FUNCTION = YES; 236 | GCC_WARN_UNUSED_VARIABLE = YES; 237 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 238 | MACOSX_DEPLOYMENT_TARGET = 13.4; 239 | MTL_ENABLE_DEBUG_INFO = NO; 240 | MTL_FAST_MATH = YES; 241 | SDKROOT = macosx; 242 | SWIFT_COMPILATION_MODE = wholemodule; 243 | }; 244 | name = Release; 245 | }; 246 | 0930F1B62DC3B64100F50558 /* Debug */ = { 247 | isa = XCBuildConfiguration; 248 | buildSettings = { 249 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 250 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 251 | CODE_SIGN_ENTITLEMENTS = Promptor/Promptor.entitlements; 252 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 253 | CODE_SIGN_STYLE = Automatic; 254 | COMBINE_HIDPI_IMAGES = YES; 255 | CURRENT_PROJECT_VERSION = 1; 256 | DEVELOPMENT_ASSET_PATHS = "\"Promptor/Preview Content\""; 257 | DEVELOPMENT_TEAM = 7WDHVZP794; 258 | ENABLE_HARDENED_RUNTIME = YES; 259 | ENABLE_PREVIEWS = YES; 260 | GENERATE_INFOPLIST_FILE = YES; 261 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 262 | LD_RUNPATH_SEARCH_PATHS = ( 263 | "$(inherited)", 264 | "@executable_path/../Frameworks", 265 | ); 266 | MARKETING_VERSION = 1.0; 267 | PRODUCT_BUNDLE_IDENTIFIER = org.Promptor; 268 | PRODUCT_NAME = "$(TARGET_NAME)"; 269 | SWIFT_EMIT_LOC_STRINGS = YES; 270 | SWIFT_VERSION = 5.0; 271 | }; 272 | name = Debug; 273 | }; 274 | 0930F1B72DC3B64100F50558 /* Release */ = { 275 | isa = XCBuildConfiguration; 276 | buildSettings = { 277 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 278 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 279 | CODE_SIGN_ENTITLEMENTS = Promptor/Promptor.entitlements; 280 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 281 | CODE_SIGN_STYLE = Automatic; 282 | COMBINE_HIDPI_IMAGES = YES; 283 | CURRENT_PROJECT_VERSION = 1; 284 | DEVELOPMENT_ASSET_PATHS = "\"Promptor/Preview Content\""; 285 | DEVELOPMENT_TEAM = 7WDHVZP794; 286 | ENABLE_HARDENED_RUNTIME = YES; 287 | ENABLE_PREVIEWS = YES; 288 | GENERATE_INFOPLIST_FILE = YES; 289 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 290 | LD_RUNPATH_SEARCH_PATHS = ( 291 | "$(inherited)", 292 | "@executable_path/../Frameworks", 293 | ); 294 | MARKETING_VERSION = 1.0; 295 | PRODUCT_BUNDLE_IDENTIFIER = org.Promptor; 296 | PRODUCT_NAME = "$(TARGET_NAME)"; 297 | SWIFT_EMIT_LOC_STRINGS = YES; 298 | SWIFT_VERSION = 5.0; 299 | }; 300 | name = Release; 301 | }; 302 | /* End XCBuildConfiguration section */ 303 | 304 | /* Begin XCConfigurationList section */ 305 | 0930F1A12DC3B64000F50558 /* Build configuration list for PBXProject "Promptor" */ = { 306 | isa = XCConfigurationList; 307 | buildConfigurations = ( 308 | 0930F1B32DC3B64100F50558 /* Debug */, 309 | 0930F1B42DC3B64100F50558 /* Release */, 310 | ); 311 | defaultConfigurationIsVisible = 0; 312 | defaultConfigurationName = Release; 313 | }; 314 | 0930F1B52DC3B64100F50558 /* Build configuration list for PBXNativeTarget "Promptor" */ = { 315 | isa = XCConfigurationList; 316 | buildConfigurations = ( 317 | 0930F1B62DC3B64100F50558 /* Debug */, 318 | 0930F1B72DC3B64100F50558 /* Release */, 319 | ); 320 | defaultConfigurationIsVisible = 0; 321 | defaultConfigurationName = Release; 322 | }; 323 | /* End XCConfigurationList section */ 324 | }; 325 | rootObject = 0930F19E2DC3B64000F50558 /* Project object */; 326 | } 327 | -------------------------------------------------------------------------------- /Promptor.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Promptor.xcodeproj/xcshareddata/xcschemes/Promptor.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 45 | 47 | 53 | 54 | 55 | 56 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /Promptor/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Promptor/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "filename" : "promptor-icon-trim-v4.png", 45 | "idiom" : "mac", 46 | "scale" : "1x", 47 | "size" : "512x512" 48 | }, 49 | { 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Promptor/Assets.xcassets/AppIcon.appiconset/promptor-icon-trim-v4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrickdch/promptor/f34d7592cba7978bf412c328f41f030ad6862de7/Promptor/Assets.xcassets/AppIcon.appiconset/promptor-icon-trim-v4.png -------------------------------------------------------------------------------- /Promptor/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Promptor/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | 4 | struct ContentView: View { 5 | @EnvironmentObject var vm: FileAggregator 6 | @State private var showingFileImporter = false 7 | @State private var pickedFolderURL: URL? 8 | @State private var showImportOptions = false 9 | @State private var showTemplatePicker = false 10 | @State private var copied = false 11 | 12 | var body: some View { 13 | NavigationSplitView { 14 | FileTreeSidebar() 15 | .environmentObject(vm) 16 | .navigationTitle("Files") 17 | .toolbar { 18 | // Forward controls to the file manager actions 19 | ToolbarItem(placement: .confirmationAction) { 20 | Button { 21 | showingFileImporter = true 22 | } label: { 23 | Label("Add Folder", systemImage: "folder.badge.plus") 24 | } 25 | } 26 | } 27 | } detail: { 28 | let tokenCount = Int(round(Double(vm.finalPrompt.count) / 4.0)) // Calculate token count 29 | 30 | VStack(alignment: .leading, spacing: 0) { 31 | HStack { 32 | Spacer() 33 | Text("\(tokenCount) tokens") // Display token count 34 | .font(.system(size: 14)) 35 | .foregroundColor(.secondary) 36 | .padding(.trailing, 8) // Add some spacing 37 | 38 | Button { 39 | NSPasteboard.general.clearContents() 40 | NSPasteboard.general.setString(vm.finalPrompt, forType: .string) 41 | copied = true 42 | DispatchQueue.main.asyncAfter(deadline: .now()+1) { copied = false } 43 | } label: { 44 | Label(copied ? "Copied!" : "Copy", systemImage: "doc.on.doc") 45 | .padding(.horizontal, 8) 46 | .padding(.vertical, 4) 47 | .background( 48 | RoundedRectangle(cornerRadius: 4) 49 | .fill(Color.gray.opacity(0.1)) 50 | ) 51 | } 52 | .buttonStyle(.plain) 53 | .help("Copy Prompt to Clipboard") 54 | } 55 | .padding([.top, .trailing]) 56 | 57 | TextEditor(text: $vm.finalPrompt) 58 | .font(.system(.body, design: .monospaced)) 59 | .padding() 60 | } 61 | .navigationTitle("Prompt") 62 | } 63 | .toolbar { 64 | PromptToolbar(showTemplatePicker: $showTemplatePicker) 65 | } 66 | .fileImporter(isPresented: $showingFileImporter, 67 | allowedContentTypes: [.folder, .item], 68 | allowsMultipleSelection: true) { res in 69 | if case let .success(urls) = res, let first = urls.first { 70 | pickedFolderURL = first 71 | showImportOptions = true 72 | } 73 | } 74 | .sheet(isPresented: $showImportOptions) { 75 | if let folder = pickedFolderURL { 76 | ImportOptionsSheet(folderURL: folder, 77 | settings: vm.settings) { confirmed, newSettings in 78 | if confirmed { 79 | vm.settings = newSettings 80 | vm.importFolder(folder) 81 | } 82 | showImportOptions = false 83 | } 84 | } 85 | } 86 | .sheet(isPresented: $showTemplatePicker) { 87 | TemplatePickerSheet(isPresented: $showTemplatePicker) 88 | .environmentObject(vm) 89 | } 90 | .frame(minWidth: 800, minHeight: 600) 91 | .onAppear { 92 | if vm.rootNode == nil { 93 | showingFileImporter = true 94 | } 95 | } 96 | } 97 | } 98 | 99 | struct FileTreeSidebar: View { 100 | @EnvironmentObject var vm: FileAggregator 101 | @State private var showingFileImporter = false 102 | 103 | var body: some View { 104 | VStack(spacing: 0) { 105 | // Add Folder button in header area - refined styling 106 | Button { 107 | showingFileImporter = true 108 | } label: { 109 | Label("Add Folder", systemImage: "folder.badge.plus") 110 | .font(.system(size: 14, weight: .medium)) 111 | .frame(maxWidth: .infinity, alignment: .leading) 112 | .padding(.horizontal, 12) 113 | .padding(.vertical, 8) 114 | } 115 | .buttonStyle(.borderless) 116 | .background(Color.accentColor.opacity(0.06)) 117 | .cornerRadius(4) 118 | .padding(.horizontal, 12) 119 | .padding(.vertical, 8) 120 | 121 | Divider() 122 | .padding(.bottom, 4) 123 | 124 | if let root = vm.rootNode { 125 | // Use List with sidebar style for automatic indentation and scrolling 126 | List { 127 | // Wrap root in an array and use correct key path 128 | OutlineGroup([root], children: \.children) { node in 129 | FileRow(node: node) 130 | // Note: EnvironmentObject is implicitly passed down, 131 | // but explicitly adding it here for clarity doesn't hurt. 132 | .environmentObject(vm) 133 | } 134 | } 135 | .listStyle(.sidebar) // Apply sidebar styling 136 | // Removed .background and .padding(.horizontal, 8) as List handles this 137 | } else { 138 | VStack { 139 | Spacer() 140 | Text("No folder selected") 141 | .foregroundColor(.secondary) 142 | Spacer() 143 | } 144 | } 145 | } 146 | .fileImporter(isPresented: $showingFileImporter, 147 | allowedContentTypes: [UTType.folder], 148 | allowsMultipleSelection: false) { result in 149 | if case let .success(urls) = result, let url = urls.first { 150 | vm.importFolder(url) 151 | } 152 | } 153 | } 154 | } 155 | 156 | struct FileRow: View { 157 | @EnvironmentObject var vm: FileAggregator 158 | let node: FileNode 159 | 160 | // Compute child counts for display 161 | private var selectionCount: String { 162 | if node.isDirectory { 163 | let counts = vm.countForNode(node) 164 | return "(\(counts.selected)/\(counts.total))" 165 | } 166 | return "" 167 | } 168 | 169 | // Determine if the node is selected based on the ViewModel's Set 170 | private var isSelected: Bool { 171 | vm.selectedNodes.contains(node) 172 | } 173 | 174 | var body: some View { 175 | HStack(spacing: 6) { // Slightly increased spacing for better readability 176 | // File/Folder icon with proper coloring 177 | Group { 178 | if node.isDirectory { 179 | Image(systemName: vm.expandedNodes.contains(node.id) ? "folder.fill" : "folder") 180 | .foregroundColor(.blue) 181 | .imageScale(.medium) 182 | } else { 183 | Image(systemName: "doc.text") 184 | .foregroundColor(.secondary) 185 | .imageScale(.small) 186 | } 187 | } 188 | .frame(width: 20, alignment: .center) 189 | 190 | // Selection checkbox 191 | Button { 192 | if node.isDirectory { 193 | vm.setSelectionRecursively(node: node, select: !isSelected) 194 | } else { 195 | vm.toggleSelection(node) 196 | } 197 | } label: { 198 | Image(systemName: isSelected ? "checkmark.square.fill" : "square") 199 | .foregroundColor(isSelected ? .blue : Color(white: 0.7)) 200 | .symbolRenderingMode(.hierarchical) // For better visual harmony 201 | .imageScale(.medium) 202 | } 203 | .buttonStyle(.plain) 204 | .frame(width: 20, alignment: .center) 205 | .contentShape(Rectangle()) 206 | 207 | // Filename + selection count for folders 208 | HStack(spacing: 4) { 209 | Text(node.name) 210 | .font(.system(size: 13)) 211 | .lineLimit(1) 212 | 213 | if node.isDirectory { 214 | Text(selectionCount) 215 | .foregroundColor(.secondary) 216 | .font(.system(size: 11)) 217 | .padding(.leading, 2) 218 | } 219 | } 220 | 221 | Spacer(minLength: 8) 222 | } 223 | // Slightly rounder corners on the selection highlight for more polish 224 | .padding(.horizontal, 4) 225 | .padding(.vertical, 3) 226 | .background( 227 | RoundedRectangle(cornerRadius: 4) 228 | .fill(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) 229 | ) 230 | .contentShape(Rectangle()) 231 | } 232 | } 233 | 234 | #Preview { 235 | ContentView() 236 | .environmentObject(FileAggregator()) 237 | } -------------------------------------------------------------------------------- /Promptor/FileAggregator.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import UniformTypeIdentifiers 4 | 5 | // MARK: - Models (should potentially move to Models.swift) 6 | 7 | // Hierarchical node structure that can represent both files and folders 8 | struct FileNode: Identifiable, Hashable { 9 | let id: String 10 | let url: URL 11 | var relativePath: String 12 | var name: String // Just the filename or directory name 13 | var isDirectory: Bool 14 | var children: [FileNode]? // For directories 15 | var content: String? // For files 16 | var isSelected: Bool = false 17 | var isExpanded: Bool = false // For UI expansion state 18 | 19 | static func == (lhs: FileNode, rhs: FileNode) -> Bool { 20 | lhs.id == rhs.id 21 | } 22 | 23 | func hash(into hasher: inout Hasher) { 24 | hasher.combine(id) 25 | } 26 | } 27 | 28 | // Template structure - Identifiable for use in ForEach 29 | struct Template: Identifiable, Hashable { 30 | var id: String { name } // Use name as the unique ID 31 | var name: String 32 | var format: String 33 | func render(with content: String) -> String { 34 | format.replacingOccurrences(of: "{{files}}", with: content) 35 | } 36 | } 37 | 38 | // MARK: - FileAggregator ViewModel 39 | 40 | class FileAggregator: ObservableObject { 41 | @Published var rootNode: FileNode? // Root of our file tree 42 | @Published var fileNodes: [FileNode] = [] // Flat list for backward compatibility 43 | @Published var selectedNodes: Set = [] 44 | @Published var expandedNodes: Set = [] // Track expanded folders by String ID 45 | @Published var finalPrompt: String = "" 46 | @Published var rootFolderURL: URL? 47 | @Published var settings = AppSettings() 48 | @Published var showRemoveIcons: Bool = true 49 | @Published var currentTemplate: Template = Template(name: "Default", format: "{{files}}") 50 | 51 | // --- NEW: Folder Watcher Properties --- 52 | private var watcher: FolderWatcher? 53 | private var cancellables = Set() 54 | private let treeDidChange = PassthroughSubject() 55 | // --- END NEW --- 56 | 57 | init() { 58 | // Try to restore previously accessed folder on launch 59 | restoreLastFolderAccess() 60 | 61 | // --- NEW: Setup Debouncer for Folder Watcher --- 62 | // Debounce rapid bursts of file system events 63 | treeDidChange 64 | .debounce(for: .milliseconds(500), scheduler: RunLoop.main) 65 | .sink { [weak self] in 66 | print("FSEvent triggered rescanTree()") // Add logging 67 | self?.rescanTree() 68 | } 69 | .store(in: &cancellables) 70 | // --- END NEW --- 71 | } 72 | 73 | // Restore access to previously selected folder if available 74 | private func restoreLastFolderAccess() { 75 | if let bookmarkData = UserDefaults.standard.data(forKey: "LastFolderBookmark") { 76 | do { 77 | var isStale = false 78 | let url = try URL(resolvingBookmarkData: bookmarkData, 79 | options: .withSecurityScope, 80 | relativeTo: nil, 81 | bookmarkDataIsStale: &isStale) 82 | 83 | if isStale { 84 | print("Bookmark is stale, not restoring previous folder") 85 | return 86 | } 87 | 88 | // We have successfully resolved the bookmark 89 | DispatchQueue.main.async { 90 | self.importFolder(url) 91 | } 92 | } catch { 93 | print("Failed to resolve bookmark: \(error)") 94 | } 95 | } 96 | } 97 | 98 | func importFolder(_ root: URL) { 99 | self.rootFolderURL = root 100 | 101 | // --- Preserve state before clearing --- 102 | let previousSelectedIDs = Set(selectedNodes.map { $0.id }) 103 | let previousExpandedIDs = expandedNodes // Keep existing expanded set 104 | // --- End Preserve --- 105 | 106 | self.fileNodes = [] // Still clear flat list if needed 107 | // Don't clear selectedNodes/expandedNodes here, restore them later 108 | 109 | let fm = FileManager.default 110 | 111 | // Ensure we have access to the folder 112 | var hasAccess = true 113 | 114 | // Store a security-scoped bookmark to maintain access to this folder 115 | do { 116 | // Store security-scoped bookmark 117 | let bookmarkData = try root.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) 118 | UserDefaults.standard.set(bookmarkData, forKey: "LastFolderBookmark") 119 | } catch { 120 | print("Failed to create security-scoped bookmark: \(error)") 121 | } 122 | 123 | // Access the folder with security scope 124 | if root.startAccessingSecurityScopedResource() { 125 | defer { root.stopAccessingSecurityScopedResource() } 126 | 127 | // Create the hierarchical structure 128 | self.rootNode = createFileTree(at: root, relativeTo: root, fileManager: fm) 129 | 130 | // Restore selection and expansion state 131 | restoreSelectionAndExpansion(selectedIDs: previousSelectedIDs, expandedIDs: previousExpandedIDs) 132 | 133 | // Recompute folder selections based on restored state 134 | recomputeFolderSelections() 135 | 136 | // Assemble initial prompt 137 | assemblePrompt() // Assemble after restoring state 138 | 139 | // Also create a flat list for backward compatibility if needed 140 | // self.fileNodes = flattenFileTree(self.rootNode) // Can be done if flat list is still used 141 | 142 | // --- NEW: Start watcher after successful import --- 143 | startWatching(root) 144 | // --- END NEW --- 145 | } else { 146 | print("Error: Cannot access security-scoped resource.") 147 | hasAccess = false 148 | } 149 | 150 | // Update UI on main thread 151 | DispatchQueue.main.async { 152 | if !hasAccess { 153 | self.finalPrompt = "Error: Cannot access the selected folder. Please check permissions." 154 | } 155 | } 156 | } 157 | 158 | // Recursively create a file tree 159 | private func createFileTree(at url: URL, relativeTo rootURL: URL, fileManager fm: FileManager) -> FileNode? { 160 | // ✱ NEW ✱ — consult user settings before doing any expensive work 161 | if url != rootURL && !settings.shouldImport(url) { 162 | return nil // excluded by suffix, folder name or size 163 | } 164 | do { 165 | // Get required attributes 166 | let resourceKeys: [URLResourceKey] = [.isDirectoryKey, .nameKey, .fileSizeKey] 167 | let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys)) 168 | 169 | // Calculate relative path 170 | let relativePath = url.path.replacingOccurrences(of: rootURL.path, with: "") 171 | .trimmingCharacters(in: CharacterSet(charactersIn: "/")) 172 | 173 | // Get name for display 174 | let name = resourceValues.name ?? url.lastPathComponent 175 | 176 | // Is this a directory or file? 177 | let isDirectory = resourceValues.isDirectory ?? false 178 | 179 | // --- NEW: Use url.path for stable ID --- 180 | let nodeID = url.path 181 | // --- END NEW --- 182 | 183 | if isDirectory { 184 | // For directories, recursively process contents 185 | var children: [FileNode] = [] 186 | 187 | if settings.includeSubfolders || url == rootURL { 188 | do { 189 | let directoryContents = try fm.contentsOfDirectory( 190 | at: url, 191 | includingPropertiesForKeys: resourceKeys, 192 | options: [.skipsHiddenFiles, .skipsPackageDescendants] 193 | ) 194 | for itemURL in directoryContents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { 195 | if let childNode = createFileTree(at: itemURL, relativeTo: rootURL, fileManager: fm) { 196 | children.append(childNode) 197 | } 198 | } 199 | } catch { 200 | print("Error listing directory contents: \(error)") 201 | } 202 | } 203 | 204 | return FileNode( 205 | id: nodeID, // NEW ID 206 | url: url, 207 | relativePath: relativePath.isEmpty ? name : relativePath, 208 | name: name, 209 | isDirectory: true, 210 | children: children, 211 | content: nil, 212 | isSelected: false, 213 | isExpanded: false // Default expansion state 214 | ) 215 | } else { 216 | // Create a file node (content loaded on demand) 217 | return FileNode( 218 | id: nodeID, // NEW ID 219 | url: url, 220 | relativePath: relativePath.isEmpty ? name : relativePath, 221 | name: name, 222 | isDirectory: false, 223 | children: nil, 224 | content: nil, 225 | isSelected: false 226 | ) 227 | } 228 | } catch { 229 | print("Error processing \(url.path): \(error)") 230 | return nil 231 | } 232 | } 233 | 234 | // Flatten the tree into a list (for backward compatibility) 235 | private func flattenFileTree(_ node: FileNode?) -> [FileNode] { 236 | guard let node = node else { return [] } 237 | 238 | var result: [FileNode] = [] 239 | 240 | // Add files (not directories) to the list 241 | if !node.isDirectory { 242 | result.append(node) 243 | } 244 | 245 | // Recursively add children 246 | if let children = node.children { 247 | for child in children { 248 | result.append(contentsOf: flattenFileTree(child)) 249 | } 250 | } 251 | 252 | return result 253 | } 254 | 255 | // Recursively collect all nodes 256 | private func collectAllNodes(_ node: FileNode?) -> [FileNode] { 257 | guard let n = node else { return [] } 258 | var arr: [FileNode] = [n] 259 | if let children = n.children { 260 | for c in children { arr.append(contentsOf: collectAllNodes(c)) } 261 | } 262 | return arr 263 | } 264 | 265 | // Toggle node selection (only flips the node, then recompute tree) 266 | func toggleSelection(_ node: FileNode) { 267 | _ = findAndUpdateNode(node.id, in: &rootNode) { n in 268 | var copy = n 269 | copy.isSelected.toggle() 270 | return copy 271 | } 272 | recomputeFolderSelections() 273 | assemblePrompt() 274 | } 275 | 276 | // Recursive selection set / unset 277 | func setSelectionRecursively(node: FileNode, select: Bool) { 278 | _ = findAndUpdateNode(node.id, in: &rootNode) { n in 279 | var copy = n; copy.isSelected = select; return copy } 280 | if let children = node.children { 281 | for child in children { 282 | setSelectionRecursively(node: child, select: select) 283 | } 284 | } 285 | recomputeFolderSelections() 286 | assemblePrompt() 287 | } 288 | 289 | // Toggle folder expansion 290 | func toggleExpansion(_ nodeID: String) { 291 | if expandedNodes.contains(nodeID) { 292 | expandedNodes.remove(nodeID) 293 | } else { 294 | expandedNodes.insert(nodeID) 295 | } 296 | 297 | // Also update the node's state if needed 298 | _ = findAndUpdateNode( 299 | nodeID, 300 | in: &rootNode, 301 | update: { node in 302 | var updatedNode = node 303 | updatedNode.isExpanded = expandedNodes.contains(nodeID) 304 | return updatedNode 305 | } 306 | ) 307 | } 308 | 309 | // Helper to find and update a node in the tree 310 | private func findAndUpdateNode( 311 | _ id: String, 312 | in node: inout FileNode?, 313 | update: (FileNode) -> FileNode 314 | ) -> FileNode? { 315 | guard let nodeUnwrapped = node else { return nil } 316 | 317 | if nodeUnwrapped.id == id { 318 | // Found the node, update it 319 | let updatedNode = update(nodeUnwrapped) 320 | node = updatedNode 321 | return updatedNode 322 | } 323 | 324 | // Search in children if this is a directory 325 | if nodeUnwrapped.isDirectory, let children = nodeUnwrapped.children { 326 | var mutableChildren = children 327 | for i in 0.. Bool { 349 | guard var n = node else { return false } 350 | if let children = n.children { 351 | var allSelected = true 352 | var newChildren = children 353 | for i in newChildren.indices { 354 | var child: FileNode? = newChildren[i] 355 | let childSelected = dfs(&child) 356 | newChildren[i] = child! 357 | allSelected = allSelected && childSelected 358 | } 359 | n.children = newChildren 360 | if !n.children!.isEmpty { 361 | n.isSelected = allSelected 362 | } 363 | } 364 | node = n 365 | return n.isSelected 366 | } 367 | dfs(&rootNode) 368 | 369 | // rebuild selectedNodes (include folders & files) 370 | let all = collectAllNodes(rootNode) 371 | selectedNodes = Set(all.filter { $0.isSelected }) 372 | } 373 | 374 | // Assemble the final prompt using only selected files 375 | func assemblePrompt() { 376 | var chunks: [String] = [] 377 | // Make sure selectedNodes is up-to-date before assembling 378 | let currentSelectedFiles = collectAllNodes(rootNode).filter { $0.isSelected && !$0.isDirectory } 379 | 380 | let sortedSelectedNodes = currentSelectedFiles.sorted { $0.relativePath < $1.relativePath } 381 | 382 | // --- Clear cache for selected files before assembling --- 383 | // This ensures we re-read content during assembly if needed 384 | for node in sortedSelectedNodes { 385 | _ = findAndUpdateNode(node.id, in: &rootNode) { n in 386 | var copy = n 387 | copy.content = nil // Clear cache 388 | return copy 389 | } 390 | } 391 | // --- End Clear Cache --- 392 | 393 | for node in sortedSelectedNodes { 394 | // Reload content by calling loadFileContent directly here 395 | // Content cache was cleared above, so this will re-read if necessary 396 | let content = loadFileContent(node.url) 397 | // Update node's content cache (optional, but can avoid re-reading immediately) 398 | // _ = findAndUpdateNode(node.id, in: &rootNode) { n in var copy = n; copy.content = content; return copy } 399 | 400 | // --- Stage 4: Skip nil content --- START 401 | guard let fileContent = content else { 402 | print("Skipping binary/unreadable file in prompt: \(node.relativePath)") 403 | continue // Skip this file if content is nil (e.g., binary) 404 | } 405 | // --- Stage 4: Skip nil content --- END 406 | 407 | let header = "```\(node.relativePath)\n" 408 | let footer = "\n```" 409 | chunks.append(header + fileContent + footer) // Use unwrapped fileContent 410 | } 411 | finalPrompt = currentTemplate.render(with: chunks.joined(separator: "\n\n")) 412 | print("Prompt assembled with \(chunks.count) text files (out of \(sortedSelectedNodes.count) selected).") // Updated log 413 | } 414 | 415 | // Get content for a specific node 416 | private func loadFileContent(_ url: URL) -> String? { 417 | print("Attempting to load content for: \(url.path)") // Add logging 418 | 419 | // 1. Security-scope dance (adapted slightly from original) 420 | let needsScope = url.path.contains(rootFolderURL?.path ?? "") && UserDefaults.standard.data(forKey: "LastFolderBookmark") != nil 421 | var accessGranted = true 422 | if needsScope { 423 | accessGranted = url.startAccessingSecurityScopedResource() 424 | if accessGranted { 425 | defer { url.stopAccessingSecurityScopedResource() } 426 | } else { 427 | // Try resolving bookmark as fallback if direct access fails 428 | print("Could not start security scope directly, trying bookmark for \(url.path)") 429 | if let bookmarkData = UserDefaults.standard.data(forKey: "LastFolderBookmark") { 430 | do { 431 | var isStale = false 432 | let resolvedURL = try URL(resolvingBookmarkData: bookmarkData, 433 | options: .withSecurityScope, 434 | relativeTo: nil, 435 | bookmarkDataIsStale: &isStale) 436 | if !isStale && resolvedURL.startAccessingSecurityScopedResource() { 437 | print("Access granted via bookmark resolution.") 438 | accessGranted = true 439 | defer { resolvedURL.stopAccessingSecurityScopedResource() } // Important: stop access on the *resolved* URL 440 | } else { 441 | print("Bookmark resolved but access still denied or stale.") 442 | accessGranted = false 443 | } 444 | } catch { 445 | print("Failed to resolve bookmark: \(error)") 446 | accessGranted = false 447 | } 448 | } else { 449 | accessGranted = false // No bookmark data 450 | } 451 | } 452 | } 453 | 454 | guard accessGranted else { 455 | print("Permission denied for \(url.path)") 456 | // Post notification about the error 457 | NotificationCenter.default.post( 458 | name: NSNotification.Name("FileAccessError"), 459 | object: "Permission denied for \(url.lastPathComponent). You may need to re-select the folder." 460 | ) 461 | return "Error: Permission denied for \(url.lastPathComponent)." 462 | } 463 | 464 | // 2. Size check (using settings) 465 | if let sz = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize, 466 | sz > settings.maxFileSize { 467 | print("File too large: \(url.lastPathComponent) (\(sz) bytes > \(settings.maxFileSize))") 468 | return "Error: File size (\(sz / 1024) KB) exceeds limit (\(settings.maxFileSize / 1024) KB)." 469 | } 470 | 471 | // 3. Fast path – try UTF-8 text 472 | do { 473 | let txt = try String(contentsOf: url, encoding: .utf8) 474 | print("Loaded as UTF-8: \(url.lastPathComponent)") 475 | return txt 476 | } catch { 477 | // Continue to next encoding attempt 478 | print("UTF-8 decoding failed for \(url.lastPathComponent), trying Latin1...") 479 | } 480 | 481 | // 4. Fallback – try ISO-8859-1 (latin-1) for arbitrary bytes -> chars 482 | do { 483 | let data = try Data(contentsOf: url) 484 | if let latin = String(data: data, encoding: .isoLatin1) { 485 | print("Loaded as ISO Latin 1: \(url.lastPathComponent)") 486 | return latin 487 | } 488 | } catch { 489 | print("Data read failed for \(url.lastPathComponent), trying Base64...") 490 | // Error reading data, proceed to Base64 attempt if possible 491 | } 492 | 493 | // 5. Last resort – Base-64 encode binary so it is still printable 494 | do { 495 | let data = try Data(contentsOf: url) 496 | print("Encoding as Base64: \(url.lastPathComponent)") 497 | return "@@BASE64@@\n" + data.base64EncodedString() 498 | } catch { 499 | print("Base64 encoding failed (cannot read data): \(url.path): \(error)") 500 | NotificationCenter.default.post( 501 | name: NSNotification.Name("FileAccessError"), 502 | object: "Failed to read file \(url.lastPathComponent) for Base64 encoding: \(error.localizedDescription)" 503 | ) 504 | return "Error: Could not read file \(url.lastPathComponent)." 505 | } 506 | } 507 | 508 | // MARK: - Expand / Collapse All 509 | func toggleTreeExpansion() { 510 | // If any directory is collapsed -> expand all else collapse all 511 | var needExpand = false 512 | func checkCollapsed(_ node: FileNode?) { 513 | guard let n = node else { return } 514 | if n.isDirectory { 515 | if !expandedNodes.contains(n.id) { needExpand = true } 516 | n.children?.forEach { checkCollapsed($0) } 517 | } 518 | } 519 | checkCollapsed(rootNode) 520 | if needExpand { 521 | // expand all 522 | func expand(_ node: FileNode?) { 523 | guard let n = node else { return } 524 | if n.isDirectory { expandedNodes.insert(n.id) } 525 | n.children?.forEach { expand($0) } 526 | } 527 | expand(rootNode) 528 | } else { 529 | expandedNodes.removeAll() 530 | } 531 | } 532 | 533 | // --- NEW: Refresh Methods --- 534 | /// Re-scans the folder hierarchy (slow but thorough), preserving state. 535 | func rescanTree() { 536 | print("Starting rescanTree...") // Add logging 537 | guard let root = rootFolderURL else { return } 538 | // Call importFolder which now preserves state 539 | importFolder(root) 540 | print("Finished rescanTree.") // Add logging 541 | } 542 | 543 | /// Only re-loads content of files already in the tree (fast). 544 | func reloadContents() { 545 | print("Starting reloadContents...") // Add logging 546 | // Collect all nodes currently in the tree 547 | let allNodesInTree = collectAllNodes(rootNode) 548 | 549 | // Filter for files (not directories) 550 | let filesToRefresh = allNodesInTree.filter { !$0.isDirectory } 551 | 552 | var cacheClearedCount = 0 553 | // Iterate through the files and clear their cached content 554 | for fileNode in filesToRefresh { 555 | // Use findAndUpdateNode to clear the content cache in the main tree structure 556 | let updatedNode = findAndUpdateNode(fileNode.id, in: &rootNode) { node in 557 | var copy = node 558 | if copy.content != nil { // Only clear if it was actually cached 559 | copy.content = nil 560 | cacheClearedCount += 1 561 | } 562 | return copy 563 | } 564 | // Optional: Log if a node wasn't found, though it shouldn't happen if collected correctly 565 | // if updatedNode == nil { print("Warning: Could not find node \(fileNode.id) to clear cache.") } 566 | } 567 | print("Cleared cache for \\(cacheClearedCount) files.") // Add logging 568 | 569 | // Re-assemble the prompt. assemblePrompt() will now call loadFileContent() 570 | // for selected files because their cache is empty. 571 | assemblePrompt() 572 | print("Finished reloadContents.") // Add logging 573 | } 574 | // --- END NEW Refresh Methods --- 575 | 576 | // --- NEW: Folder Watcher Management --- 577 | private func startWatching(_ folder: URL) { 578 | // Stop existing watcher if any 579 | watcher?.stop() 580 | watcher = nil 581 | 582 | // Create and start a new watcher 583 | watcher = FolderWatcher(url: folder) { [weak self] in 584 | print("FolderWatcher event received.") // Add logging 585 | self?.treeDidChange.send() // Signal that something changed 586 | } 587 | if watcher == nil { 588 | print("Error: Failed to initialize FolderWatcher for \(folder.path)") 589 | } 590 | } 591 | // --- END NEW --- 592 | } 593 | 594 | // MARK: - Selection Count Helpers (Moved into Extension) 595 | extension FileAggregator { 596 | func countForNode(_ node: FileNode) -> (selected: Int, total: Int) { 597 | if !node.isDirectory { 598 | return (node.isSelected ? 1 : 0, 1) 599 | } 600 | 601 | var selected = 0 602 | var total = 0 603 | 604 | if let children = node.children { 605 | for child in children { 606 | let childCount = countForNode(child) 607 | selected += childCount.selected 608 | total += childCount.total 609 | } 610 | } 611 | 612 | return (selected, total) 613 | } 614 | } 615 | 616 | extension FileAggregator { 617 | /// Deselect every node and rebuild the prompt. 618 | func clearSelections() { 619 | // 1. Walk the tree and mark every node unselected. 620 | func deselect(_ node: inout FileNode?) { 621 | guard var n = node else { return } 622 | n.isSelected = false 623 | if var children = n.children { 624 | for i in children.indices { 625 | var child: FileNode? = children[i] 626 | deselect(&child) 627 | children[i] = child! 628 | } 629 | n.children = children 630 | } 631 | node = n 632 | } 633 | 634 | deselect(&rootNode) 635 | 636 | // 2. Empty the selection set and prompt. 637 | selectedNodes.removeAll() 638 | assemblePrompt() 639 | } 640 | 641 | /// Remove all imported files and reset state. 642 | func removeAll() { 643 | rootNode = nil 644 | fileNodes.removeAll() 645 | selectedNodes.removeAll() 646 | expandedNodes.removeAll() 647 | finalPrompt = "" 648 | rootFolderURL = nil 649 | showRemoveIcons = true 650 | } 651 | } 652 | 653 | // --- NEW: Restore Selection and Expansion State --- 654 | extension FileAggregator { 655 | private func restoreSelectionAndExpansion(selectedIDs: Set, expandedIDs: Set) { 656 | self.selectedNodes = [] // Clear before restoring 657 | self.expandedNodes = expandedIDs // Directly restore expanded IDs 658 | 659 | func restoreRecursively(_ node: inout FileNode?) { 660 | guard var n = node else { return } 661 | 662 | if selectedIDs.contains(n.id) { 663 | n.isSelected = true 664 | self.selectedNodes.insert(n) // Add to the set 665 | } else { 666 | n.isSelected = false // Ensure others are not selected 667 | } 668 | 669 | n.isExpanded = expandedIDs.contains(n.id) // Restore expansion 670 | 671 | if var children = n.children { 672 | for i in children.indices { 673 | var child: FileNode? = children[i] 674 | restoreRecursively(&child) 675 | children[i] = child! // Assign back the potentially modified child 676 | } 677 | n.children = children 678 | } 679 | node = n // Assign back the potentially modified node 680 | } 681 | 682 | restoreRecursively(&rootNode) 683 | } 684 | } 685 | -------------------------------------------------------------------------------- /Promptor/FolderWatcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreServices 3 | 4 | final class FolderWatcher { 5 | private var stream: FSEventStreamRef? 6 | private let callback: () -> Void 7 | private let path: String // Store the path to ensure it's valid during deinit 8 | 9 | init?(url: URL, latency: TimeInterval = 0.3, callback: @escaping () -> Void) { 10 | guard url.isFileURL else { 11 | print("Error: FolderWatcher can only watch file URLs.") 12 | return nil 13 | } 14 | self.callback = callback 15 | // Ensure the path string remains valid for the C callback context. 16 | // Using fileSystemRepresentation ensures correct encoding. 17 | var pathChars = [CChar](repeating: 0, count: Int(PATH_MAX)) 18 | guard (url as NSURL).getFileSystemRepresentation(&pathChars, maxLength: pathChars.count) else { 19 | print("Error: Could not get file system representation for URL: \(url)") 20 | return nil 21 | } 22 | self.path = String(cString: pathChars) 23 | 24 | 25 | // Pass `self` as context info. `unsafeBitCast` is necessary for C interop. 26 | var context = FSEventStreamContext(version: 0, info: Unmanaged.passUnretained(self).toOpaque(), retain: nil, release: nil, copyDescription: nil) 27 | 28 | stream = FSEventStreamCreate( 29 | kCFAllocatorDefault, 30 | { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in 31 | // Check if clientCallBackInfo is non-nil before proceeding 32 | guard let clientCallBackInfo = clientCallBackInfo else { return } 33 | // Safely cast context info back to FolderWatcher instance. 34 | let handler = Unmanaged.fromOpaque(clientCallBackInfo).takeUnretainedValue() 35 | // Callback on the main thread for UI updates. 36 | DispatchQueue.main.async { handler.callback() } 37 | }, 38 | &context, 39 | [self.path] as CFArray, // Use the stored path string 40 | FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 41 | latency, 42 | UInt32(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagUseCFTypes) // Added UseCFTypes for safety with CFArray 43 | ) 44 | 45 | guard let stream = stream else { 46 | print("Error: Failed to create FSEventStream.") 47 | return nil 48 | } 49 | 50 | FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) 51 | guard FSEventStreamStart(stream) else { 52 | print("Error: Failed to start FSEventStream.") 53 | FSEventStreamInvalidate(stream) // Clean up if start fails 54 | FSEventStreamRelease(stream) 55 | self.stream = nil // Ensure stream is nil if start failed 56 | return nil 57 | } 58 | print("FolderWatcher started for path: \(self.path)") 59 | } 60 | 61 | func stop() { 62 | guard let stream = stream else { return } 63 | FSEventStreamStop(stream) 64 | FSEventStreamInvalidate(stream) 65 | FSEventStreamRelease(stream) 66 | self.stream = nil 67 | print("FolderWatcher stopped for path: \(path)") 68 | } 69 | 70 | 71 | deinit { 72 | stop() // Ensure stream is stopped and released on deinit 73 | } 74 | } -------------------------------------------------------------------------------- /Promptor/ImportOptionsSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ImportOptionsSheet: View { 4 | let folderURL: URL 5 | @State var settings: AppSettings 6 | var completion: (Bool, AppSettings) -> Void 7 | 8 | var body: some View { 9 | VStack(alignment: .leading, spacing: 16) { 10 | Text("Add Folder") 11 | .font(.title3.bold()) 12 | 13 | Text("Adding **\(folderURL.lastPathComponent)** to the context list") 14 | .foregroundStyle(.secondary) 15 | 16 | // ───────── SUB-FOLDER HANDLING ───────── 17 | Text("Sub-folders Handling") 18 | .font(.headline) 19 | 20 | Picker("", selection: $settings.includeSubfolders) { 21 | Text("Include files from sub-folders").tag(true) 22 | Text("Exclude files from sub-folders").tag(false) 23 | } 24 | .pickerStyle(.radioGroup) 25 | .labelsHidden() 26 | 27 | // ───────── IGNORE SUFFIXES ───────── 28 | LabeledContent("Ignore file suffixes (comma-separated):") { 29 | TextField("e.g. .env,.class", text: Binding( 30 | get: { settings.ignoreSuffixes.sorted().joined(separator: ",") }, 31 | set: { settings.ignoreSuffixes = Set($0.split(separator: ",").map{ "." + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) }) } 32 | )) 33 | .textFieldStyle(.roundedBorder) 34 | } 35 | 36 | // ───────── IGNORE FOLDERS ───────── 37 | LabeledContent("Ignore folders (comma-separated):") { 38 | TextField("e.g. build,dist,.next", text: Binding( 39 | get: { settings.ignoreFolders.sorted().joined(separator: ",") }, 40 | set: { settings.ignoreFolders = Set($0.split(separator: ",").map{ $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) } 41 | )) 42 | .textFieldStyle(.roundedBorder) 43 | } 44 | 45 | Text("Media files, binary files and files larger than **\(settings.maxFileSize/1024) KB** are automatically ignored.") 46 | .font(.footnote) 47 | .foregroundStyle(.secondary) 48 | .fixedSize(horizontal: false, vertical: true) 49 | 50 | // ───────── ACTIONS ───────── 51 | HStack { 52 | Button("Reset to default") { settings = AppSettings() } 53 | Spacer() 54 | Button("Cancel") { completion(false, settings) } 55 | Button("Confirm") { completion(true, settings) }.keyboardShortcut(.defaultAction) 56 | } 57 | } 58 | .padding(24) 59 | .frame(width: 440) 60 | } 61 | } -------------------------------------------------------------------------------- /Promptor/Models.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | struct AppSettings: Codable, Equatable { 5 | // MARK: - User-visible options 6 | var includeSubfolders : Bool = true 7 | var ignoreSuffixes : Set = DefaultIgnore.suffixes 8 | var ignoreFolders : Set = DefaultIgnore.folders 9 | var maxFileSize : Int = 500 * 1024 // Bytes 10 | 11 | // MARK: - Hard rules (never exposed in UI) 12 | 13 | /// Central gatekeeper used by the importer. 14 | func shouldImport(_ url: URL) -> Bool { 15 | // Folder name filters – cheap early exit 16 | if url.hasDirectoryPath, 17 | url.pathComponents.contains(where: { ignoreFolders.contains($0.lowercased()) }) { 18 | return false 19 | } 20 | 21 | // Suffix filters (only files reach here) 22 | if !url.hasDirectoryPath, 23 | ignoreSuffixes.contains("." + url.pathExtension.lowercased()) { 24 | return false 25 | } 26 | 27 | // File size gate 28 | if let sz = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize, 29 | sz > maxFileSize { 30 | return false 31 | } 32 | 33 | return true 34 | } 35 | } 36 | 37 | /// Canonical ignore lists. Trim/extend as you see fit. 38 | enum DefaultIgnore { 39 | static let suffixes: Set = [ 40 | // binary / media 41 | ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", 42 | ".mp4", ".mov", ".mp3", ".wav", 43 | // archives & build artefacts 44 | ".zip", ".tar", ".gz", ".rar", ".7z", 45 | ".class", ".jar", ".war", ".ear", ".o", ".a", ".so", ".dylib", 46 | ".exe", ".dll", ".app", ".bin", ".pyc", 47 | // editor & OS noise 48 | ".ds_store", "thumbs.db" 49 | ] 50 | 51 | static let folders: Set = [ 52 | // VCS & editors 53 | ".git", ".github", ".svn", ".hg", ".idea", ".vscode", 54 | // dependency / build output 55 | "node_modules", "Pods", "Carthage", "target", 56 | "build", "dist", "out", "deriveddata", ".next", ".parcel-cache", 57 | // misc 58 | ".venv", ".mypy_cache", ".gradle", ".terraform" 59 | ] 60 | } -------------------------------------------------------------------------------- /Promptor/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Promptor/PromptToolbar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PromptToolbar: ToolbarContent { 4 | @EnvironmentObject var vm: FileAggregator 5 | @Binding var showTemplatePicker: Bool 6 | 7 | var body: some ToolbarContent { 8 | ToolbarItemGroup { 9 | Button { 10 | showTemplatePicker = true 11 | } label: { 12 | Label("Select Template", systemImage: "bookmark.ribbon") 13 | } 14 | .help("Select Template") 15 | 16 | Button { 17 | vm.toggleTreeExpansion() 18 | } label: { 19 | Label("Expand / Collapse All", systemImage: "plus.square.on.square") 20 | } 21 | .help("Expand or Collapse All Folders") 22 | 23 | Button { vm.reloadContents() } label: { 24 | Label("Refresh Files", systemImage: "arrow.clockwise") 25 | } 26 | .help("Force refresh file content") 27 | 28 | Button { vm.clearSelections() } label: { 29 | Label("Unselect All", systemImage: "xmark.rectangle") 30 | } 31 | .help("Unselect all files") 32 | 33 | Toggle(isOn: $vm.showRemoveIcons) { 34 | Label("Show Remove Button", systemImage: "eye.slash") 35 | } 36 | .toggleStyle(.button) 37 | .help("Hide / Show remove file button column") 38 | 39 | Button(role: .destructive) { vm.removeAll() } label: { 40 | Label("Remove All", systemImage: "trash") 41 | } 42 | .help("Remove all files from list") 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Promptor/Promptor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.files.bookmarks.app-scope 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Promptor/PromptorApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PromptorApp.swift 3 | // Promptor 4 | // 5 | // Created by Edrick Da Corte Henriquez on 5/1/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct PromptorApp: App { 12 | @StateObject private var vm = FileAggregator() 13 | 14 | init() { 15 | // Disable automatic Metal usage if possible 16 | if let _ = UserDefaults.standard.object(forKey: "NSUseMetalRenderer") { 17 | // Setting already exists, don't override 18 | } else { 19 | UserDefaults.standard.set(false, forKey: "NSUseMetalRenderer") 20 | } 21 | } 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | ContentView() 26 | .environmentObject(vm) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Promptor/TemplatePickerSheet.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TemplatePickerSheet: View { 4 | @EnvironmentObject var vm: FileAggregator 5 | @Binding var isPresented: Bool 6 | /// The templates the user can choose from 7 | private let templates: [Template] = [ 8 | .init(name: "Default", format: "{{files}}"), 9 | .init( 10 | name: "ChatML", 11 | format: """ 12 | <|im_start|>system 13 | You are a helpful assistant.<|im_end|> 14 | <|im_start|>user 15 | {{files}} 16 | <|im_end|> 17 | <|im_start|>assistant 18 | """ 19 | ) 20 | ] 21 | var body: some View { 22 | NavigationView { 23 | List { 24 | ForEach(templates, id: \.id) { tpl in 25 | Button(tpl.name) { 26 | vm.currentTemplate = tpl 27 | vm.assemblePrompt() 28 | isPresented = false 29 | } 30 | } 31 | } 32 | .navigationTitle("Select Template") 33 | .toolbar { 34 | ToolbarItem(placement: .confirmationAction) { 35 | Button("Close") { isPresented = false } 36 | } 37 | } 38 | } 39 | .frame(width: 300, height: 300) 40 | } 41 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Promptor 3 | 4 | **Turn any codebase into a single, clean prompt – in seconds.** 5 | 6 |

7 | App display 8 |

9 | 10 | [📺 **Live 20-second demo on X**](https://x.com/edrick_dch/status/1919219856320160161) 11 | 12 | 13 | [![Twitter Follow](https://img.shields.io/twitter/follow/edrick_dch?style=social)](https://twitter.com/edrick_dch) 14 | 15 | --- 16 | 17 | Promptor is a tiny macOS app that lets you _drag-in_ a folder and _drag-out_ a perfectly-formatted prompt ready for ChatGPT (or any LLM). 18 | 19 | No server-side processing, no API keys, no rate limits – just a local SwiftUI app that gives you the full context of your project in one click. 20 | 21 | > “I built Promptor after realising I was paying US $0.30 per O3 request in Cursor. With ChatGPT Plus I already get 100 O3 requests every week… so why not copy the entire repo into the chat? Promptor is that copy-and-paste button.” – [@edrickdch](https://github.com/edrickdch) 22 | 23 | --- 24 | 25 | ## ✨ Key Features 26 | 27 | * **One-click import** – choose any folder; Promptor filters out binaries, images, build artefacts, etc. 28 | * **Smart ignore rules** – defaults modelled after `.gitignore` + binary/media filters; adjustable before every import. 29 | * **Folder-aware selection** – recursively include/exclude sub-trees with a single checkbox; selection counts update live. 30 | * **Live token counter** – rough GPT-token estimate so you know when to stop adding files. 31 | * **Template system** – swap between `Default`, `ChatML`, or roll your own with `{{files}}` placeholder. 32 | * **Security-scoped bookmarks** – full sandbox compliance; Promptor never uploads or phones home. 33 | * **Zero dependencies** – pure Swift + SwiftUI, 100 % local. 34 | 35 | --- 36 | 37 | ## 🚀 Quick Start 38 | 39 | ### 1. Clone & open 40 | 41 | ```bash 42 | git clone https://github.com/edrickdch/Promptor.git 43 | open Promptor/Promptor.xcodeproj # or .xcworkspace if you add packages 44 | ``` 45 | 46 | ### 2. Build 47 | 48 | * Xcode 15 or newer 49 | * macOS 14 (Sonoma) SDK 50 | 51 | Press `⌘R` to run. The first time you import a folder macOS will ask for permissions; Promptor stores a security-scoped bookmark so you don’t have to re-grant every launch. 52 | 53 | ### 3. Use 54 | 55 | 1. **Add Folder** → pick your repo 56 | 2. (Optional) tweak ignore suffixes / folders 57 | 3. Check the files or folders you want 58 | 4. Copy → paste into ChatGPT (or anywhere) 59 | 60 | Done. 61 | 62 | --- 63 | 64 | ## 🔧 Advanced 65 | 66 | | Setting | Location | Default | 67 | | ------------------- | ------------------------- | --------------------------- | 68 | | Include sub-folders | Import sheet | ✅ | 69 | | Ignore suffixes | Import sheet (comma-sep) | `.png,.jpg,.zip,…` | 70 | | Ignore folders | Import sheet (comma-sep) | `node_modules,build,.git,…` | 71 | | Max file size | `AppSettings.maxFileSize` | `500 KB` | 72 | 73 | Edit [`Models.swift`](Promptor/Models.swift) to change globals. 74 | 75 | --- 76 | 77 | ## Why Use Promptor? 78 | 79 | 1. Simple & Easy to Use 80 | 2. Open Source 81 | 3. Free 82 | 83 | --- 84 | 85 | ## 🤝 Contributing 86 | 87 | PRs welcome! Please open an issue first if you’re planning a major change. 88 | 89 | ```bash 90 | git checkout -b feature/your-awesome-feature 91 | git commit -m "Add amazing thing" 92 | git push origin feature/your--awesome-feature 93 | ``` 94 | 95 | --- 96 | 97 | ## 📝 License 98 | 99 | MIT © 2025 Edrick Da Corte Henriquez. See [`LICENSE`](LICENSE) for details. 100 | 101 | --- 102 | -------------------------------------------------------------------------------- /docs/screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edrickdch/promptor/f34d7592cba7978bf412c328f41f030ad6862de7/docs/screenshots/screenshot.png --------------------------------------------------------------------------------