diff --git a/macos/Podfile.lock b/macos/Podfile.lock index a8e051c0..9819dd63 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1841,7 +1841,7 @@ PODS: - Yoga - Sentry/HybridSDK (8.56.1) - SocketRocket (0.7.1) - - Sparkle (2.8.1) + - Sparkle (2.9.0) - Yoga (0.0.0) DEPENDENCIES: @@ -2173,7 +2173,7 @@ SPEC CHECKSUMS: RNSentry: eeaaa1a61ce59874fb46bba120042464618723e6 Sentry: b3ec44d01708fce73f99b544beb57e890eca4406 SocketRocket: 03f7111df1a343b162bf5b06ead333be808e1e0a - Sparkle: a346a4341537c625955751ed3ae4b340b68551fa + Sparkle: f4355f9ebbe9b7d932df4980d70f13922ac97b2a Yoga: be08366e61155f388be00b3943f4b3febdef8d30 PODFILE CHECKSUM: 09d9f4d8690650f7bbc4a5e78de155d44c82904c diff --git a/macos/sol-macOS/AppDelegate.swift b/macos/sol-macOS/AppDelegate.swift index 5ec0b9a4..7a446e2e 100644 --- a/macos/sol-macOS/AppDelegate.swift +++ b/macos/sol-macOS/AppDelegate.swift @@ -110,6 +110,26 @@ class AppDelegate: RCTAppDelegate { return } + // Check for image data on the pasteboard (supports all image types via NSImage) + let imageTypes: Set = [ + "public.tiff", "public.png", "public.jpeg", + "public.heic", "com.compuserve.gif", "com.microsoft.bmp" + ] + let hasExplicitImageType = $0.types?.contains(where: { imageTypes.contains($0.rawValue) }) ?? false + if hasExplicitImageType, + let image = NSImage(pasteboard: $0), + let pngRepData = image.PNGRepresentation { + let filename = "clipboard_\(Int(Date().timeIntervalSince1970 * 1000)).png" + let tempFile = NSTemporaryDirectory() + filename + do { + try pngRepData.write(to: URL(fileURLWithPath: tempFile), options: .atomic) + SolEmitter.sharedInstance.fileCopied(filename, tempFile, bundle) + } catch { + print("Could not save clipboard image: \(error.localizedDescription)") + } + return + } + // Try to parse first as string let txt = $0.string(forType: .string) if txt != nil { diff --git a/macos/sol-macOS/lib/ClipboardHelper.swift b/macos/sol-macOS/lib/ClipboardHelper.swift index dd8bbb60..a025555d 100644 --- a/macos/sol-macOS/lib/ClipboardHelper.swift +++ b/macos/sol-macOS/lib/ClipboardHelper.swift @@ -14,8 +14,7 @@ class ClipboardHelper { self, selector: #selector(frontmostAppChanged(sender:)), name: NSWorkspace.didActivateApplicationNotification, object: nil) - // TODO find if there is any better way to observe for changes other than continously check if the amount of items has changed - Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in if pasteboard.changeCount != changeCount { callback(pasteboard, self.frontmostApp) changeCount = pasteboard.changeCount @@ -76,4 +75,23 @@ class ClipboardHelper { event2?.post(tap: CGEventTapLocation.cghidEventTap) } } + + static func pasteFileToFrontmostApp(_ filePath: String) { + DispatchQueue.main.async { + PanelManager.shared.hideWindow() + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + let url = URL(fileURLWithPath: filePath) + pasteboard.writeObjects([url as NSURL]) + + let event1 = CGEvent(keyboardEventSource: nil, virtualKey: 0x09, keyDown: true) // cmd-v down + event1?.flags = CGEventFlags.maskCommand + event1?.post(tap: CGEventTapLocation.cghidEventTap) + + let event2 = CGEvent(keyboardEventSource: nil, virtualKey: 0x09, keyDown: false) // cmd-v up + event2?.post(tap: CGEventTapLocation.cghidEventTap) + } + } } diff --git a/macos/sol-macOS/lib/FileSearch.h b/macos/sol-macOS/lib/FileSearch.h index 2261bed1..3e9b58d4 100644 --- a/macos/sol-macOS/lib/FileSearch.h +++ b/macos/sol-macOS/lib/FileSearch.h @@ -4,12 +4,15 @@ #include #include +static const int MAX_SEARCH_DEPTH = 5; +static const size_t MAX_SEARCH_RESULTS = 200; + struct File { std::string path; bool is_folder; std::string name; }; -std::vector search_files(NSString *basePath, NSString *query); +std::vector search_files(NSString *basePath, NSString *query, int depth = 0, size_t *result_count = nullptr); #endif /* FileSearch_h */ diff --git a/macos/sol-macOS/lib/FileSearch.mm b/macos/sol-macOS/lib/FileSearch.mm index d8a45f73..a002457a 100644 --- a/macos/sol-macOS/lib/FileSearch.mm +++ b/macos/sol-macOS/lib/FileSearch.mm @@ -18,40 +18,79 @@ #include "FileSearch.h" #include "NSString+Score.h" -std::vector search_files(NSString *basePath, NSString *query) { +static NSSet *getSkippedDirectories() { + static NSSet *skippedDirectories = [NSSet setWithObjects: + @"node_modules", @".git", @"Library", @".cache", + @".Trash", @"__pycache__", @".npm", @".yarn", + @"Pods", @"build", @"DerivedData", nil]; + return skippedDirectories; +} + +static bool shouldSkipDirectory(NSString *name) { + if ([name hasPrefix:@"."]) return true; + if ([name hasSuffix:@".app"]) return true; + if ([name hasSuffix:@".framework"]) return true; + if ([getSkippedDirectories() containsObject:name]) return true; + return false; +} + +std::vector search_files(NSString *basePath, NSString *query, int depth, size_t *result_count) { std::vector files; + + size_t localCount = 0; + if (!result_count) { + result_count = &localCount; + } + + if (depth >= MAX_SEARCH_DEPTH || *result_count >= MAX_SEARCH_RESULTS) { + return files; + } + NSFileManager *defFM = [NSFileManager defaultManager]; NSError *error = nil; NSArray *dirPath = [defFM contentsOfDirectoryAtPath:basePath error:&error]; + if (!dirPath) { + return files; + } + for(NSString *path in dirPath) { + if (*result_count >= MAX_SEARCH_RESULTS) { + break; + } + BOOL is_dir; NSString *full_path = [basePath stringByAppendingPathComponent:path]; std::string cpp_full_path = [full_path UTF8String]; float distance = [path scoreAgainst:query]; - - if([defFM fileExistsAtPath:full_path isDirectory:&is_dir] && is_dir){ + + if([defFM fileExistsAtPath:full_path isDirectory:&is_dir] && is_dir) { + if (shouldSkipDirectory(path)) { + continue; + } + if (distance > 0.5) { files.push_back({ .path = cpp_full_path, .is_folder = true, .name = [path UTF8String] }); + (*result_count)++; } - std::vector sub_files = search_files(full_path, query); - files.insert(files.end(), sub_files.begin(), sub_files.end()); - } else { - - if (distance > 0.5) { + std::vector sub_files = search_files(full_path, query, depth + 1, result_count); + files.insert(files.end(), sub_files.begin(), sub_files.end()); + } else { + if (distance > 0.5) { files.push_back({ .path = cpp_full_path, .is_folder = false, .name = [path UTF8String] }); + (*result_count)++; } - } + } } - - return files; + + return files; } diff --git a/macos/sol-macOS/lib/JSIBindings.mm b/macos/sol-macOS/lib/JSIBindings.mm index b144d2d1..0e9a0b77 100644 --- a/macos/sol-macOS/lib/JSIBindings.mm +++ b/macos/sol-macOS/lib/JSIBindings.mm @@ -163,11 +163,16 @@ void install(jsi::Runtime &rt, auto paths = arguments[0].asObject(rt).asArray(rt); auto query = arguments[1].asString(rt).utf8(rt); std::vector res; + size_t result_count = 0; for (size_t i = 0; i < paths.size(rt); i++) { + if (result_count >= MAX_SEARCH_RESULTS) { + break; + } auto path = paths.getValueAtIndex(rt, i).asString(rt).utf8(rt); std::vector path_results = search_files([NSString stringWithUTF8String:path.c_str()], - [NSString stringWithUTF8String:query.c_str()]); + [NSString stringWithUTF8String:query.c_str()], + 0, &result_count); res.insert(res.end(), path_results.begin(), path_results.end()); } diff --git a/macos/sol-macOS/lib/SolNative.mm b/macos/sol-macOS/lib/SolNative.mm index 768e1431..2ff89fc6 100644 --- a/macos/sol-macOS/lib/SolNative.mm +++ b/macos/sol-macOS/lib/SolNative.mm @@ -68,6 +68,7 @@ - (void)loadBundle { RCT_EXTERN_METHOD(moveFrontmostToNextSpace) RCT_EXTERN_METHOD(insertToFrontmostApp : (NSString)content) RCT_EXTERN_METHOD(pasteToFrontmostApp : (NSString)content) +RCT_EXTERN_METHOD(pasteFileToFrontmostApp : (NSString)filePath) RCT_EXTERN_METHOD(turnOnHorizontalArrowsListeners) RCT_EXTERN_METHOD(turnOffHorizontalArrowsListeners) RCT_EXTERN_METHOD(turnOffEnterListener) @@ -107,7 +108,7 @@ - (void)loadBundle { RCT_EXTERN_METHOD(showWifiQR : (NSString)SSID password : (NSString)password) RCT_EXTERN_METHOD(openFilePicker : (RCTPromiseResolveBlock) resolve reject : (RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(updateHotkeys : (NSDictionary)hotkeys) +RCT_EXTERN_METHOD(updateHotkeys : (NSDictionary)hotkeys urlMap : (NSDictionary)urlMap) RCT_EXTERN_METHOD(setUpcomingEventEnabled : (BOOL)enabled) RCT_EXTERN_METHOD(setHyperKeyEnabled : (BOOL)v) @end diff --git a/macos/sol-macOS/lib/SolNative.swift b/macos/sol-macOS/lib/SolNative.swift index 5df3e39b..5b3d2115 100644 --- a/macos/sol-macOS/lib/SolNative.swift +++ b/macos/sol-macOS/lib/SolNative.swift @@ -292,6 +292,10 @@ class SolNative: RCTEventEmitter { ClipboardHelper.insertToFrontmostApp(content) } + @objc func pasteFileToFrontmostApp(_ filePath: String) { + ClipboardHelper.pasteFileToFrontmostApp(filePath) + } + @objc func turnOnHorizontalArrowsListeners() { HotKeyManager.shared.catchHorizontalArrowsPress = true } @@ -437,9 +441,10 @@ class SolNative: RCTEventEmitter { } } - @objc func updateHotkeys(_ hotkeys: NSDictionary) { + @objc func updateHotkeys(_ hotkeys: NSDictionary, urlMap: NSDictionary?) { guard let hotkeys = hotkeys as? [String: String] else { return } - HotKeyManager.shared.updateHotkeys(hotkeyMap: hotkeys) + let urls = urlMap as? [String: String] ?? [:] + HotKeyManager.shared.updateHotkeys(hotkeyMap: hotkeys, urlMap: urls) } @objc func setUpcomingEventEnabled(_ enabled: Bool) { diff --git a/macos/sol-macOS/managers/HotKeyManager.swift b/macos/sol-macOS/managers/HotKeyManager.swift index ad3a2bbf..68d24ed6 100644 --- a/macos/sol-macOS/managers/HotKeyManager.swift +++ b/macos/sol-macOS/managers/HotKeyManager.swift @@ -154,7 +154,7 @@ final class HotKeyManager { } } - func updateHotkeys(hotkeyMap: [String: String]) { + func updateHotkeys(hotkeyMap: [String: String], urlMap: [String: String] = [:]) { hotkeys.removeAll() for (key, value) in hotkeyMap { @@ -205,8 +205,15 @@ final class HotKeyManager { guard let finalKey = keyValue else { continue } let hotKey = HotKey(key: finalKey, modifiers: modifiers) - hotKey.keyUpHandler = { - SolEmitter.sharedInstance.onHotkey(id: key) + // If the hotkey has a URL, open it directly in native (skip JS round-trip) + if let url = urlMap[key] { + hotKey.keyUpHandler = { + NSWorkspace.shared.openFile(url) + } + } else { + hotKey.keyUpHandler = { + SolEmitter.sharedInstance.onHotkey(id: key) + } } hotkeys.append(hotKey) } diff --git a/macos/sol-macOS/views/FileImageView.m b/macos/sol-macOS/views/FileImageView.m new file mode 100644 index 00000000..30e39c57 --- /dev/null +++ b/macos/sol-macOS/views/FileImageView.m @@ -0,0 +1,5 @@ +#import + +@interface RCT_EXTERN_MODULE (FileImageViewManager, RCTViewManager) +RCT_EXPORT_VIEW_PROPERTY(url, NSString) +@end diff --git a/macos/sol-macOS/views/FileImageView.swift b/macos/sol-macOS/views/FileImageView.swift new file mode 100644 index 00000000..5180d936 --- /dev/null +++ b/macos/sol-macOS/views/FileImageView.swift @@ -0,0 +1,42 @@ +import Cocoa + +class FileImageView: NSView { + let imageView = NSImageView() + + @objc var url: NSString = "" { + didSet { + self.setupView() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.autoresizingMask = [.height, .width] + self.addSubview(imageView) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + private func setupView() { + let path = (self.url as String).replacingOccurrences( + of: "file://", with: "") + guard !path.isEmpty else { return } + let image = NSImage(contentsOfFile: path) + self.imageView.image = image + } +} + +@objc(FileImageViewManager) +class FileImageViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> NSView! { + return FileImageView() + } +} diff --git a/macos/sol.xcodeproj/project.pbxproj b/macos/sol.xcodeproj/project.pbxproj index f0c804f4..30c56400 100644 --- a/macos/sol.xcodeproj/project.pbxproj +++ b/macos/sol.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 700A18D8279DBBE40096C830 /* ApplicationSearcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700A18D7279DBBE40096C830 /* ApplicationSearcher.swift */; }; 700A18DA279DC17F0096C830 /* FileIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700A18D9279DC17F0096C830 /* FileIcon.swift */; }; 700A18DC279DC1F50096C830 /* FileIcon.m in Sources */ = {isa = PBXBuildFile; fileRef = 700A18DB279DC1F50096C830 /* FileIcon.m */; }; + C10A18DA279DC17F0096C830 /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10A18D9279DC17F0096C830 /* FileImageView.swift */; }; + C10A18DC279DC1F50096C830 /* FileImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = C10A18DB279DC1F50096C830 /* FileImageView.m */; }; 700A99F22C33D6E30049831F /* QRGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700A99F12C33D6E30049831F /* QRGenerator.swift */; }; 700DF99A28266F0B00C5FD0B /* ClipboardHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700DF99928266F0B00C5FD0B /* ClipboardHelper.swift */; }; 700F266729F501C8002CA8F1 /* BlurView.m in Sources */ = {isa = PBXBuildFile; fileRef = 700F266629F501C8002CA8F1 /* BlurView.m */; }; @@ -85,6 +87,8 @@ 700A18D7279DBBE40096C830 /* ApplicationSearcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSearcher.swift; sourceTree = ""; }; 700A18D9279DC17F0096C830 /* FileIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIcon.swift; sourceTree = ""; }; 700A18DB279DC1F50096C830 /* FileIcon.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FileIcon.m; sourceTree = ""; }; + C10A18D9279DC17F0096C830 /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = ""; }; + C10A18DB279DC1F50096C830 /* FileImageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FileImageView.m; sourceTree = ""; }; 700A99F12C33D6E30049831F /* QRGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRGenerator.swift; sourceTree = ""; }; 700DF99928266F0B00C5FD0B /* ClipboardHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardHelper.swift; sourceTree = ""; }; 700F266629F501C8002CA8F1 /* BlurView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BlurView.m; sourceTree = ""; }; @@ -240,6 +244,8 @@ 706ABFAD2E059FCF000C0F30 /* KeyboardShortcutRecorderView.swift */, 700A18D9279DC17F0096C830 /* FileIcon.swift */, 700A18DB279DC1F50096C830 /* FileIcon.m */, + C10A18D9279DC17F0096C830 /* FileImageView.swift */, + C10A18DB279DC1F50096C830 /* FileImageView.m */, 70B78360279BE866008660F5 /* Panel.swift */, 70D1220128DA95C500179A1D /* Overlay.swift */, 70B927602930ADC100F883A0 /* Toast.swift */, @@ -371,7 +377,8 @@ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1340; + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2630; TargetAttributes = { 514201482437B4B30078DB4F = { CreatedOnToolsVersion = 11.4; @@ -432,7 +439,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\nBUNDLE_REACT_NATIVE=\"/bin/sh $SENTRY_XCODE $REACT_NATIVE_XCODE\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT \\\"$BUNDLE_REACT_NATIVE\\\"\"\n"; + shellScript = "set -e\n\nexport SENTRY_DISABLE_AUTO_UPLOAD=${SENTRY_DISABLE_AUTO_UPLOAD:-true}\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\nBUNDLE_REACT_NATIVE=\"/bin/sh $SENTRY_XCODE $REACT_NATIVE_XCODE\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT \\\"$BUNDLE_REACT_NATIVE\\\"\"\n"; }; 381D8A6F24576A6C00465D17 /* Start Packager */ = { isa = PBXShellScriptBuildPhase; @@ -529,7 +536,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh ../node_modules/@sentry/react-native/scripts/sentry-xcode-debug-files.sh\n"; + shellScript = "export SENTRY_DISABLE_AUTO_UPLOAD=${SENTRY_DISABLE_AUTO_UPLOAD:-true}\n/bin/sh ../node_modules/@sentry/react-native/scripts/sentry-xcode-debug-files.sh\n"; }; 9ACD38329CAF517BAD27034C /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; @@ -629,6 +636,7 @@ 70142B3B2D21812500EA565F /* KeychainAccess.swift in Sources */, 70D1220228DA95C500179A1D /* Overlay.swift in Sources */, 700A18DC279DC1F50096C830 /* FileIcon.m in Sources */, + C10A18DC279DC1F50096C830 /* FileImageView.m in Sources */, 70805244298F692E000DB574 /* MouseUtils.swift in Sources */, 70EFF77428D5F732009FE998 /* DoNotDisturb.swift in Sources */, 7042A22A2815C06B00874743 /* NSColor.extension.swift in Sources */, @@ -637,6 +645,7 @@ 70E15D082931FC57007FFE5F /* FS.swift in Sources */, 700DF99A28266F0B00C5FD0B /* ClipboardHelper.swift in Sources */, 700A18DA279DC17F0096C830 /* FileIcon.swift in Sources */, + C10A18DA279DC17F0096C830 /* FileImageView.swift in Sources */, 706ABFAE2E059FCF000C0F30 /* KeyboardShortcutRecorderView.swift in Sources */, 706ABFAF2E059FCF000C0F30 /* KeyboardShortcutRecorderView.m in Sources */, 70754B532BECECAB00B77EF8 /* FileSearch.mm in Sources */, @@ -666,15 +675,21 @@ baseConfigurationReference = 434AA4134E3E6A10DF347D75 /* Pods-macOS.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + AUTOMATION_APPLE_EVENTS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "sol-macOS/sol-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = NO; - DEVELOPMENT_TEAM = 24CMR7378R; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = FFRGZZ3V5A; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_USER_SELECTED_FILES = readonly; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = "sol-macos/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = SolDebug; @@ -706,15 +721,21 @@ baseConfigurationReference = 95E8C102E7DEC7DFB286A43C /* Pods-macOS.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + AUTOMATION_APPLE_EVENTS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "sol-macOS/sol-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = NO; - DEVELOPMENT_TEAM = 24CMR7378R; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = FFRGZZ3V5A; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_USER_SELECTED_FILES = readonly; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; INFOPLIST_FILE = "sol-macos/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -772,6 +793,7 @@ COPY_PHASE_STRIP = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -789,7 +811,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -803,6 +825,7 @@ OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; USE_HERMES = true; }; @@ -841,6 +864,7 @@ COPY_PHASE_STRIP = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; @@ -854,7 +878,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -867,6 +891,7 @@ OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; USE_HERMES = true; VALIDATE_PRODUCT = YES; diff --git a/macos/sol.xcodeproj/xcshareddata/xcschemes/debug.xcscheme b/macos/sol.xcodeproj/xcshareddata/xcschemes/debug.xcscheme index f1753bbc..16a99472 100644 --- a/macos/sol.xcodeproj/xcshareddata/xcschemes/debug.xcscheme +++ b/macos/sol.xcodeproj/xcshareddata/xcschemes/debug.xcscheme @@ -1,6 +1,6 @@ ("FileImageView"); + +export const FileImageView = (props: any) => { + return ; +}; + +cssInterop(FileImageView, { + className: "style", +}); diff --git a/src/lib/SolNative.ts b/src/lib/SolNative.ts index c62f34a9..687920bb 100644 --- a/src/lib/SolNative.ts +++ b/src/lib/SolNative.ts @@ -36,6 +36,7 @@ class SolNative extends NativeEventEmitter { moveFrontmostToNextSpace: () => void moveFrontmostToPreviousSpace: () => void pasteToFrontmostApp: (content: string) => void + pasteFileToFrontmostApp: (filePath: string) => void insertToFrontmostApp: (content: string) => void turnOnHorizontalArrowsListeners: () => void @@ -88,7 +89,7 @@ class SolNative extends NativeEventEmitter { openFilePicker: () => Promise showWindow: typeof global.__SolProxy.showWindow showWifiQR: (ssid: string, password: string) => void - updateHotkeys: (v: Record) => void + updateHotkeys: (v: Record, urlMap?: Record) => void log: (message: string) => void getApplications: typeof global.__SolProxy.getApplications setHyperKeyEnabled: (v: boolean) => void @@ -139,6 +140,7 @@ class SolNative extends NativeEventEmitter { this.moveFrontmostPrevScreen = module.moveFrontmostPrevScreen this.moveFrontmostCenter = module.moveFrontmostCenter this.pasteToFrontmostApp = module.pasteToFrontmostApp + this.pasteFileToFrontmostApp = module.pasteFileToFrontmostApp this.insertToFrontmostApp = module.insertToFrontmostApp this.turnOnHorizontalArrowsListeners = module.turnOnHorizontalArrowsListeners diff --git a/src/stores/clipboard.store.tsx b/src/stores/clipboard.store.tsx index d7f0a0f3..6ec4b67b 100644 --- a/src/stores/clipboard.store.tsx +++ b/src/stores/clipboard.store.tsx @@ -6,12 +6,15 @@ import type { IRootStore } from "store"; import { Widget } from "./ui.store"; import MiniSearch from "minisearch"; import { storage } from "./storage"; -import { captureException } from "@sentry/react-native"; + const MAX_ITEMS = 1000; +const MAX_TEXT_INDEX_LENGTH = 500; // Only index first N chars for search +const PERSIST_DEBOUNCE_MS = 2000; let onTextCopiedListener: EmitterSubscription | undefined; let onFileCopiedListener: EmitterSubscription | undefined; +let persistTimer: ReturnType | undefined; export type ClipboardStore = ReturnType; @@ -20,27 +23,118 @@ export type PasteItem = { text: string; url?: string | null; bundle?: string | null; - datetime: number; // Unix timestamp when copied + datetime: number; + pinned?: boolean; }; +// Simple hash for fast duplicate detection instead of full string comparison +function hashText(text: string): string { + const len = text.length; + if (len === 0) return "0"; + // Use length + first/middle/last chars + a simple rolling hash on a sample + const sampleSize = Math.min(len, 256); + let h = len; + for (let i = 0; i < sampleSize; i++) { + const idx = Math.floor((i * len) / sampleSize); + h = (Math.imul(h, 31) + text.charCodeAt(idx)) | 0; + } + return `${len}:${h}`; +} + const minisearch = new MiniSearch({ - fields: ["text"], - storeFields: ["id", "text", "url", "bundle", "datetime"], - // tokenize: (text: string, fieldName?: string) => - // text.toLowerCase().split(/[\s\.-]+/), + fields: ["indexText"], + storeFields: ["id", "datetime"], }); export const createClipboardStore = (root: IRootStore) => { - const store = makeAutoObservable({ - deleteItem: (index: number) => { - if (index >= 0 && index < store.items.length) { - minisearch.remove(store.items[index]); - store.items.splice(index, 1); + // Hash map for O(1) duplicate detection: hash -> index in items array + const textHashMap = new Map(); + + function rebuildHashMap() { + textHashMap.clear(); + for (let i = 0; i < store.items.length; i++) { + const hash = hashText(store.items[i].text); + // Only store first occurrence (latest, since items are sorted newest-first) + if (!textHashMap.has(hash)) { + textHashMap.set(hash, i); } + } + } + + function findDuplicateIndex(text: string): number { + const hash = hashText(text); + const candidateIdx = textHashMap.get(hash); + if (candidateIdx === undefined || candidateIdx >= store.items.length) { + return -1; + } + // Verify it's actually the same text (hash collision check) + if (store.items[candidateIdx].text === text) { + return candidateIdx; + } + return -1; + } + + function addToIndex(item: PasteItem) { + try { + minisearch.add({ + id: item.id, + indexText: item.text.slice(0, MAX_TEXT_INDEX_LENGTH), + datetime: item.datetime, + }); + } catch { + // ignore duplicate id errors + } + } + + function removeFromIndex(item: PasteItem) { + try { + minisearch.discard(item.id); + } catch { + // ignore missing id errors + } + } + + const store = makeAutoObservable({ + clipboardMenuOpen: false, + _persistVersion: 0, + deleteItem: (displayIndex: number) => { + const displayItems = store.clipboardItems; + const item = displayItems[displayIndex]; + if (!item) return; + const rawIndex = store.items.findIndex((i) => i.id === item.id); + if (rawIndex === -1) return; + removeFromIndex(item); + store.items.splice(rawIndex, 1); + rebuildHashMap(); }, deleteAllItems: () => { store.items = []; minisearch.removeAll(); + textHashMap.clear(); + }, + toggleClipboardMenu: () => { + store.clipboardMenuOpen = !store.clipboardMenuOpen; + }, + closeClipboardMenu: () => { + store.clipboardMenuOpen = false; + }, + togglePin: (displayIndex: number) => { + const displayItems = store.clipboardItems; + const item = displayItems[displayIndex]; + if (!item) return; + const rawIndex = store.items.findIndex((i) => i.id === item.id); + if (rawIndex === -1) return; + store.items.splice(rawIndex, 1, { + ...store.items[rawIndex], + pinned: !store.items[rawIndex].pinned, + }); + store._persistVersion++; + const newIndex = store.clipboardItems.findIndex( + (i) => i.id === item.id, + ); + if (newIndex !== -1) { + root.ui.selectedIndex = newIndex; + } }, items: [] as PasteItem[], saveHistory: false, @@ -55,27 +149,9 @@ export const createClipboardStore = (root: IRootStore) => { ...obj, }; - // If save history move file to more permanent storage - if (store.saveHistory) { - // TODO! - } - - // const index = store.items.findIndex(t => t.text === newItem.text) - // // Item already exists, move to top - // if (index !== -1) { - // // Re-add to minisearch to update the order - // minisearch.remove(store.items[index]) - // minisearch.add(newItem) - - // store.popToTop(index) - // return - // } - - // Item does not already exist, put to queue and add to minisearch store.items.unshift(newItem); - minisearch.add(newItem); - - // Remove last item from minisearch + addToIndex(newItem); + rebuildHashMap(); store.removeLastItemIfNeeded(); }, onTextCopied: (obj: { text: string; bundle: string | null }) => { @@ -83,73 +159,98 @@ export const createClipboardStore = (root: IRootStore) => { return; } - const newItem: PasteItem = { - id: Date.now().valueOf(), - datetime: Date.now(), - ...obj, - }; - - const index = store.items.findIndex((t) => t.text === newItem.text); + const index = findDuplicateIndex(obj.text); // Item already exists, move to top if (index !== -1) { - // Re-add to minisearch to update the order - minisearch.remove(store.items[index]); - minisearch.add(store.items[index]); - store.popToTop(index); return; } - // Item does not already exist, put to queue and add to minisearch - store.items.unshift(newItem); - minisearch.add(newItem); + const newItem: PasteItem = { + id: Date.now().valueOf(), + datetime: Date.now(), + ...obj, + }; - // Remove last item from minisearch + store.items.unshift(newItem); + addToIndex(newItem); + textHashMap.set(hashText(newItem.text), 0); + // Shift all existing hash map entries by 1 + rebuildHashMap(); store.removeLastItemIfNeeded(); }, get clipboardItems(): PasteItem[] { const items = store.items; if (!root.ui.query || root.ui.focusedWidget !== Widget.CLIPBOARD) { - return items; + const hasPinned = items.some((i) => i.pinned); + if (!hasPinned) return items.slice(); + const pinned: PasteItem[] = []; + const unpinned: PasteItem[] = []; + for (const item of items) { + if (item.pinned) pinned.push(item); + else unpinned.push(item); + } + return [...pinned, ...unpinned]; } - // Boost recent items in search results const now = Date.now(); - return minisearch.search(root.ui.query, { + const results = minisearch.search(root.ui.query, { boostDocument: (documentId, term, storedFields) => { const dt = typeof storedFields?.datetime === "number" ? storedFields.datetime : Number(storedFields?.datetime); if (!dt || Number.isNaN(dt)) return 1; - // Boost items copied in the last 24h, scale down for older const hoursAgo = (now - dt) / (1000 * 60 * 60); - if (hoursAgo < 1) return 1.2; // very recent - if (hoursAgo < 24) return 1.1; // recent + if (hoursAgo < 1) return 1.2; + if (hoursAgo < 24) return 1.1; return 1; }, - // boost: { text: 2 }, - // prefix: true, - // fuzzy: 0.1, - }) as any; + }); + + // Map search results back to full items by id + const idToItem = new Map(); + for (const item of items) { + idToItem.set(item.id, item); + } + const mapped = results + .map((r) => idToItem.get(r.id as number)) + .filter(Boolean) as PasteItem[]; + const hasPinned = mapped.some((i) => i.pinned); + if (!hasPinned) return mapped; + const pinned: PasteItem[] = []; + const unpinned: PasteItem[] = []; + for (const item of mapped) { + if (item.pinned) pinned.push(item); + else unpinned.push(item); + } + return [...pinned, ...unpinned]; }, removeLastItemIfNeeded: () => { - if (store.items.length > MAX_ITEMS) { - try { - minisearch.remove(store.items[store.items.length - 1]); - } catch (e) { - captureException(e); + while (store.items.length > MAX_ITEMS) { + // Find the last unpinned item to evict + let evictIdx = -1; + for (let i = store.items.length - 1; i >= 0; i--) { + if (!store.items[i].pinned) { + evictIdx = i; + break; + } } - - store.items = store.items.slice(0, MAX_ITEMS); + if (evictIdx === -1) break; // all items are pinned + const [removed] = store.items.splice(evictIdx, 1); + removeFromIndex(removed); } }, popToTop: (index: number) => { - const newItems = [...store.items]; - const item = newItems.splice(index, 1); - newItems.unshift(item[0]); - store.items = newItems; + const [item] = store.items.splice(index, 1); + item.datetime = Date.now(); + store.items.unshift(item); + rebuildHashMap(); + // Update MiniSearch so datetime boost stays correct + removeFromIndex(item); + addToIndex(item); + store._persistVersion++; }, setSaveHistory: (v: boolean) => { store.saveHistory = v; @@ -162,6 +263,10 @@ export const createClipboardStore = (root: IRootStore) => { onTextCopiedListener = undefined; onFileCopiedListener?.remove(); onFileCopiedListener = undefined; + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = undefined; + } }, }); @@ -169,10 +274,11 @@ export const createClipboardStore = (root: IRootStore) => { "onTextCopied", store.onTextCopied, ); - // onFileCopiedListener = solNative.addListener( - // 'onFileCopied', - // store.onFileCopied, - // ) + + onFileCopiedListener = solNative.addListener( + "onFileCopied", + store.onFileCopied, + ); const hydrate = async () => { let state: string | null | undefined; @@ -197,63 +303,66 @@ export const createClipboardStore = (root: IRootStore) => { if (entry) { let items = JSON.parse(entry); - // Ensure all items have datetime items = items.map((item: any) => ({ ...item, datetime: typeof item.datetime === "number" && !Number.isNaN(item.datetime) ? item.datetime - : item.id || Date.now(), // fallback: use id or now + : item.id || Date.now(), })); runInAction(() => { store.items = items; - minisearch.addAll(store.items); + for (const item of store.items) { + addToIndex(item); + } + rebuildHashMap(); }); } } }; - const persist = async () => { + const doPersist = async () => { if (store.saveHistory) { - // Ensure all items have datetime before persisting - const itemsToPersist = store.items.map((item) => ({ - ...item, - datetime: - typeof item.datetime === "number" && !Number.isNaN(item.datetime) - ? item.datetime - : item.id || Date.now(), - })); try { await solNative.securelyStore( "@sol.clipboard_history_v2", - JSON.stringify(itemsToPersist), + JSON.stringify(store.items), ); } catch (e) { console.warn("Could not persist data", e); } } - const storeWithoutItems = { ...store }; - storeWithoutItems.items = []; - + const storeState = { saveHistory: store.saveHistory }; try { await AsyncStorage.setItem( "@clipboard.store", - JSON.stringify(storeWithoutItems), + JSON.stringify(storeState), ); } catch (e) { await AsyncStorage.clear(); await AsyncStorage.setItem( "@clipboard.store", - JSON.stringify(storeWithoutItems), + JSON.stringify(storeState), ).catch((e) => - console.warn("Could re-persist persist clipboard store", e), + console.warn("Could re-persist clipboard store", e), ); } }; hydrate().then(() => { - autorun(persist); + autorun(() => { + // Touch observables to track them + const _ = store.items.length; + const __ = store.saveHistory; + const ___ = store._persistVersion; + + // Debounce the actual persist + if (persistTimer) { + clearTimeout(persistTimer); + } + persistTimer = setTimeout(doPersist, PERSIST_DEBOUNCE_MS); + }); }); return store; diff --git a/src/stores/keystroke.store.ts b/src/stores/keystroke.store.ts index 5240a2d9..b8a2facc 100644 --- a/src/stores/keystroke.store.ts +++ b/src/stores/keystroke.store.ts @@ -30,6 +30,22 @@ export const createKeystrokeStore = (root: IRootStore) => { meta: boolean; shift: boolean; }) => { + // Handle clipboard menu when open + if ( + root.clipboard.clipboardMenuOpen && + root.ui.focusedWidget === Widget.CLIPBOARD && + keyCode !== 55 && // let modifier keys pass through + keyCode !== 60 && + keyCode !== 59 + ) { + // Cmd+P - toggle pin + if (keyCode === 35 && meta) { + root.clipboard.togglePin(root.ui.selectedIndex); + } + root.clipboard.closeClipboardMenu(); + return; + } + switch (keyCode) { // "j" key case 38: { @@ -41,6 +57,10 @@ export const createKeystrokeStore = (root: IRootStore) => { } // "k" key case 40: { + if (meta && root.ui.focusedWidget === Widget.CLIPBOARD) { + root.clipboard.toggleClipboardMenu(); + return; + } if (store.controlPressed) { store.keyDown({ keyCode: 126, meta: false, shift: false }); } @@ -66,6 +86,14 @@ export const createKeystrokeStore = (root: IRootStore) => { } break; } + // "p" key + case 35: { + if (meta && root.ui.focusedWidget === Widget.CLIPBOARD) { + root.clipboard.togglePin(root.ui.selectedIndex); + return; + } + break; + } // "e" key case 14: { if ( @@ -148,13 +176,14 @@ export const createKeystrokeStore = (root: IRootStore) => { const entry = root.clipboard.clipboardItems[root.ui.selectedIndex]; - const originalIndex = root.clipboard.clipboardItems.findIndex( - (e) => entry === e, - ); - - root.clipboard.popToTop(originalIndex); - if (entry) { + const originalIndex = root.clipboard.items.findIndex( + (e) => e.id === entry.id, + ); + if (originalIndex !== -1) { + root.clipboard.popToTop(originalIndex); + } + if (meta) { try { Linking.openURL(entry.text); @@ -162,6 +191,8 @@ export const createKeystrokeStore = (root: IRootStore) => { // console.log('could not open in browser') } solNative.hideWindow(); + } else if (entry.url) { + solNative.pasteFileToFrontmostApp(entry.url); } else { solNative.pasteToFrontmostApp(entry.text); } @@ -458,21 +489,9 @@ export const createKeystrokeStore = (root: IRootStore) => { return; } - switch (root.ui.focusedWidget) { - case Widget.SEARCH: - case Widget.EMOJIS: - case Widget.SCRATCHPAD: - case Widget.CLIPBOARD: - case Widget.GOOGLE_MAP: - solNative.hideWindow(); - break; - - default: - root.ui.setQuery(""); - break; - } - + root.ui.setQuery(""); root.ui.focusWidget(Widget.SEARCH); + solNative.hideWindow(); break; } @@ -698,7 +717,7 @@ export const createKeystrokeStore = (root: IRootStore) => { case Widget.CLIPBOARD: { root.ui.selectedIndex = Math.min( root.ui.selectedIndex + 1, - root.clipboard.items.length - 1, + root.clipboard.clipboardItems.length - 1, ); break; } diff --git a/src/stores/systemPreferences.tsx b/src/stores/systemPreferences.tsx index 0dc16ddd..0bdb3d4d 100644 --- a/src/stores/systemPreferences.tsx +++ b/src/stores/systemPreferences.tsx @@ -97,11 +97,16 @@ const globalPanes = solNative.exists(GLOBAL_PREFERENCE_PANES) ) : []; -const userPanes = solNative.exists(USER_PREFERENCE_PANES) - ? solNative +let userPanes: ReturnType[] = []; +try { + if (solNative.exists(USER_PREFERENCE_PANES)) { + userPanes = solNative .ls(USER_PREFERENCE_PANES) - .map((pane) => extractObjectFromPrefPanePath(USER_PREFERENCE_PANES, pane)) - : []; + .map((pane) => extractObjectFromPrefPanePath(USER_PREFERENCE_PANES, pane)); + } +} catch { + // Sandboxed apps may not have permission to read ~/Library/PreferencePanes +} const panes: { name: string; preferenceId: string }[] = [ ...systemPanes, diff --git a/src/stores/ui.store.tsx b/src/stores/ui.store.tsx index cdb776e6..b2dd942f 100644 --- a/src/stores/ui.store.tsx +++ b/src/stores/ui.store.tsx @@ -35,6 +35,8 @@ let onHotkeyListener: EmitterSubscription | undefined; let onAppsChangedListener: EmitterSubscription | undefined; let appareanceListener: NativeEventSubscription | undefined; let bookmarksDisposer: IReactionDisposer | undefined; +let fileSearchTimer: ReturnType | undefined; +let fileSearchDisposer: IReactionDisposer | undefined; export enum Widget { ONBOARDING = "ONBOARDING", @@ -115,8 +117,17 @@ const itemsThatShouldShowWindow = [ "clipboard_manager", "process_manager", "scratchpad", + "file_search", ]; +const itemIdToWidget: Record = { + emoji_picker: Widget.EMOJIS, + clipboard_manager: Widget.CLIPBOARD, + process_manager: Widget.PROCESSES, + scratchpad: Widget.SCRATCHPAD, + file_search: Widget.FILE_SEARCH, +}; + function getInitials(name: string) { return name .toLowerCase() @@ -242,7 +253,7 @@ export const createUIStore = (root: IRootStore) => { ); solNative.setMediaKeyForwardingEnabled(store.mediaKeyForwardingEnabled); solNative.setHyperKeyEnabled(store.hyperKeyEnabled); - solNative.updateHotkeys(toJS(store.shortcuts)); + solNative.updateHotkeys(toJS(store.shortcuts), {}); store.username = solNative.userName(); store.getApps(); @@ -291,6 +302,7 @@ export const createUIStore = (root: IRootStore) => { firstTranslationLanguage: "en" as string, secondTranslationLanguage: "de" as string, thirdTranslationLanguage: null as null | string, + fileSearchResults: [] as Item[], fileResults: [] as FileDescription[], calendarEnabled: true, showAllDayEvents: true, @@ -327,28 +339,7 @@ export const createUIStore = (root: IRootStore) => { // | | // |_| get files(): Item[] { - if (!!store.query && store.focusedWidget === Widget.FILE_SEARCH) { - runInAction(() => { - store.isLoading = true; - }); - const fileResults = solNative.searchFiles( - toJS(store.searchFolders), - store.query, - ); - - const results = fileResults.map((f) => ({ - id: f.path, - type: ItemType.FILE, - name: f.name, - url: f.path, - })); - runInAction(() => { - store.isLoading = false; - }); - return results; - } - - return []; + return store.fileSearchResults; }, get items(): Item[] { const allItems = [ @@ -665,6 +656,7 @@ export const createUIStore = (root: IRootStore) => { getApps: () => { solNative.getApplications().then((apps) => { store.updateApps(apps); + store.syncHotkeys(); }); }, onShow: ({ target }: { target?: string }) => { @@ -722,6 +714,11 @@ export const createUIStore = (root: IRootStore) => { onAppsChangedListener?.remove(); appareanceListener?.remove(); bookmarksDisposer?.(); + fileSearchDisposer?.(); + if (fileSearchTimer) { + clearTimeout(fileSearchTimer); + fileSearchTimer = undefined; + } }, getCalendarAccess: () => { store.calendarAuthorizationStatus = @@ -747,6 +744,9 @@ export const createUIStore = (root: IRootStore) => { store.focusWidget(Widget.SEARCH); } else { store.focusWidget(Widget.CLIPBOARD); + const items = root.clipboard.clipboardItems; + const firstUnpinned = items.findIndex((i) => !i.pinned); + store.selectedIndex = firstUnpinned >= 0 ? firstUnpinned : 0; } }, showProcessManager: () => { @@ -949,8 +949,19 @@ export const createUIStore = (root: IRootStore) => { }, onHotkey({ id }: { id: string }) { - const item = store.items.find((i) => i.id === id); + // Widget hotkeys use direct lookup (O(1)) instead of scanning all items + const targetWidget = itemIdToWidget[id]; + if (targetWidget) { + if (store.isVisible && store.focusedWidget === targetWidget) { + store.setQuery(""); + store.focusWidget(Widget.SEARCH); + solNative.hideWindow(); + return; + } + } + // For items that need callbacks, find and execute + const item = store.items.find((i) => i.id === id); if (item == null) { return; } @@ -961,9 +972,24 @@ export const createUIStore = (root: IRootStore) => { solNative.openFile(item.url); } - if (itemsThatShouldShowWindow.includes(item.id)) { - setTimeout(solNative.showWindow, 0); + if (itemsThatShouldShowWindow.includes(id)) { + solNative.showWindow(); + } + }, + + syncHotkeys() { + const shortcuts = toJS(store.shortcuts); + const urlMap: Record = {}; + const allItems = [ + ...store.apps, + ...store.customItems, + ]; + for (const item of allItems) { + if (shortcuts[item.id] && item.url) { + urlMap[item.id] = item.url; + } } + solNative.updateHotkeys(shortcuts, urlMap); }, setShortcut(id: string, shortcut: string) { @@ -979,12 +1005,12 @@ export const createUIStore = (root: IRootStore) => { } store.shortcuts[id] = shortcut; - solNative.updateHotkeys(toJS(store.shortcuts)); + store.syncHotkeys(); }, restoreDefaultShorcuts() { store.shortcuts = defaultShortcuts; - solNative.updateHotkeys(defaultShortcuts); + store.syncHotkeys(); }, setWindowHeight(e: LayoutChangeEvent) { @@ -1085,6 +1111,40 @@ export const createUIStore = (root: IRootStore) => { }, ); + fileSearchDisposer = reaction( + () => [store.query, store.focusedWidget] as const, + ([query, widget]) => { + if (fileSearchTimer) { + clearTimeout(fileSearchTimer); + fileSearchTimer = undefined; + } + + if (!query || widget !== Widget.FILE_SEARCH) { + store.fileSearchResults = []; + store.isLoading = false; + return; + } + + store.isLoading = true; + fileSearchTimer = setTimeout(() => { + const fileResults = solNative.searchFiles( + toJS(store.searchFolders), + query, + ); + + runInAction(() => { + store.fileSearchResults = fileResults.map((f) => ({ + id: f.path, + type: ItemType.FILE, + name: f.name, + url: f.path, + })); + store.isLoading = false; + }); + }, 200); + }, + ); + hydrate().then(() => { autorun(persist); store.getCalendarAccess(); diff --git a/src/widgets/clipboard.widget.tsx b/src/widgets/clipboard.widget.tsx index 2224bbef..b84647a7 100644 --- a/src/widgets/clipboard.widget.tsx +++ b/src/widgets/clipboard.widget.tsx @@ -1,12 +1,14 @@ import { LegendList, type LegendListRef } from "@legendapp/list"; import clsx from "clsx"; import { FileIcon } from "components/FileIcon"; +import { FileImageView } from "components/FileImageView"; import { Key } from "components/Key"; import { LoadingBar } from "components/LoadingBar"; import { MainInput } from "components/MainInput"; import { observer } from "mobx-react-lite"; import { type FC, useEffect, useRef } from "react"; import { + ScrollView, StyleSheet, Text, TouchableOpacity, @@ -21,6 +23,14 @@ interface Props { className?: string; } +const MAX_PREVIEW_LENGTH = 5000; + +function truncateText(text: string | undefined | null): string { + if (!text) return ""; + if (text.length <= MAX_PREVIEW_LENGTH) return text; + return text.slice(0, MAX_PREVIEW_LENGTH) + `\n\n... (${text.length.toLocaleString()} chars total)`; +} + const RenderItem = observer( ({ item, index }: { item: PasteItem; index: number }) => { const store = useStore(); @@ -37,14 +47,21 @@ const RenderItem = observer( "opacity-80": !isActive, })} > - + {isPngOrJpg(item.url) ? ( + + ) : ( + + )} - {item.text.trim()} + {isPngOrJpg(item.url) ? item.text : item.text.trim()} - {/* {!!item.url && ( - - )} */} + {item.pinned && ( + + {"\u{1F4CC}"} + + )} ); }, @@ -99,7 +123,6 @@ export const ClipboardWidget: FC = observer(() => { = observer(() => { recycleItems ListEmptyComponent={ - [ ] + No items } renderItem={RenderItem} @@ -115,22 +138,31 @@ export const ClipboardWidget: FC = observer(() => { {!!data[selectedIndex] && ( - + {!data[selectedIndex].url && ( - - {data[selectedIndex]?.text ?? []} + + {truncateText(data[selectedIndex]?.text)} )} - {/* {!!data[selectedIndex].url && - isPngOrJpg(data[selectedIndex].url) && ( - - )} */} + {!!data[selectedIndex].url && + isPngOrJpg(data[selectedIndex].url) && ( + + + + {data[selectedIndex].text} + + + )} {!!data[selectedIndex].url && !isPngOrJpg(data[selectedIndex].url) && ( @@ -143,12 +175,57 @@ export const ClipboardWidget: FC = observer(() => { )} - + )} {/* Shortcut bar at the bottom */} + + {store.clipboard.clipboardMenuOpen && !!data[selectedIndex] && ( + + + + Actions + + { + store.clipboard.togglePin(selectedIndex); + store.clipboard.closeClipboardMenu(); + }} + className="flex-row items-center gap-2 px-3 py-1.5 rounded" + > + + {data[selectedIndex]?.pinned ? "Unpin" : "Pin to top"} + + + + + + + )} + + More + + + + + Delete Item diff --git a/src/widgets/processes.widget.tsx b/src/widgets/processes.widget.tsx index 49820924..a7c47501 100644 --- a/src/widgets/processes.widget.tsx +++ b/src/widgets/processes.widget.tsx @@ -1,5 +1,6 @@ import {LegendList, LegendListRef} from '@legendapp/list' import clsx from 'clsx' +import {FileIcon} from 'components/FileIcon' import {Key} from 'components/Key' import {MainInput} from 'components/MainInput' import {observer} from 'mobx-react-lite' @@ -9,6 +10,13 @@ import {Text, View} from 'react-native' import {useStore} from 'store' import {Process} from 'stores/processes.store' +function getIconPath(process: Process): string | null { + const appMatch = process.path.match(/^(.*?\.app)\b/) + if (appMatch) return appMatch[1] + if (process.path) return process.path + return null +} + const RenderItem = observer(({item, index}: any) => { const store = useStore() const selectedIndex = store.ui.selectedIndex @@ -16,15 +24,14 @@ const RenderItem = observer(({item, index}: any) => { const isActive = index === selectedIndex return ( - {/* - {process.pid} - */} + {getIconPath(process) ? ( + + ) : ( + + )}