From 780ef9378fb524fbb6a1d8f71d90f13ad1aeae02 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 7 Apr 2026 14:32:06 -0400 Subject: [PATCH] feat: custom native file picker showing hidden files/folders Replaced file_picker's Load/Browse with a custom NativePickerRegistrar Swift plugin that opens NSOpenPanel with showsHiddenFiles = true. The file_picker package hardcodes this to false, making hidden folders like ~/.claude invisible in its dialogs. Changes: - New NativePickerRegistrar.swift: custom NSOpenPanel with hidden files - New NativePicker Dart service using method channel - Browse: only shows folders (canChooseFiles=false), hidden visible - Load: only shows .jsonl files, hidden folders visible - Registered via AppDelegate.applicationDidFinishLaunching - Removed file_picker dependency from home_screen imports - Fixed all info-level lint issues (super params, null-aware, doc comment) - Signed, notarized, stapled DMG --- lib/models/content_block.dart | 12 ++-- lib/screens/home/home_screen.dart | 19 +++--- lib/services/native_picker.dart | 41 ++++++++++++ macos/Runner.xcodeproj/project.pbxproj | 4 ++ macos/Runner/AppDelegate.swift | 9 +++ macos/Runner/NativePickerRegistrar.swift | 84 ++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 lib/services/native_picker.dart create mode 100644 macos/Runner/NativePickerRegistrar.swift diff --git a/lib/models/content_block.dart b/lib/models/content_block.dart index db96e65..16e042b 100644 --- a/lib/models/content_block.dart +++ b/lib/models/content_block.dart @@ -22,8 +22,8 @@ class ContentBlock { class TextBlock extends ContentBlock { final String text; - TextBlock({required this.text, required Map raw}) - : super(type: 'text', raw: raw); + TextBlock({required this.text, required super.raw}) + : super(type: 'text'); factory TextBlock.fromJson(Map json) { return TextBlock( @@ -40,8 +40,8 @@ class ThinkingBlock extends ContentBlock { ThinkingBlock({ required this.thinking, this.signature, - required Map raw, - }) : super(type: 'thinking', raw: raw); + required super.raw, + }) : super(type: 'thinking'); factory ThinkingBlock.fromJson(Map json) { return ThinkingBlock( @@ -63,8 +63,8 @@ class ToolUseBlock extends ContentBlock { required this.name, required this.input, this.linkedResult, - required Map raw, - }) : super(type: 'tool_use', raw: raw); + required super.raw, + }) : super(type: 'tool_use'); factory ToolUseBlock.fromJson(Map json) { return ToolUseBlock( diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index c5db2ee..1ed40be 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/session_provider.dart'; +import '../../services/native_picker.dart'; import '../../theme/app_theme.dart'; // ─── Data models ───────────────────────────────────────────── @@ -120,7 +120,7 @@ class _HomeScreenState extends State { } /// Returns the real user home directory, even inside App Sandbox. - /// In sandbox, HOME points to ~/Library/Containers//Data. + /// In sandbox, HOME points to `~/Library/Containers/bundleid/Data`. String? _getRealHome() { final home = Platform.environment['HOME']; if (home == null) return null; @@ -333,8 +333,9 @@ class _HomeScreenState extends State { Future _pickFolder() async { try { - final result = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Select a folder containing Claude session files', + final home = _getRealHome() ?? ''; + final result = await NativePicker.getDirectoryPath( + initialDirectory: '$home/.claude/projects', ); if (result != null) { _pathController.text = result; @@ -407,13 +408,13 @@ class _HomeScreenState extends State { Future _loadSingleFile() async { try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, + final home = _getRealHome() ?? ''; + final result = await NativePicker.pickFile( allowedExtensions: ['jsonl'], - dialogTitle: 'Select a Claude session file (.jsonl)', + initialDirectory: '$home/.claude/projects', ); - if (result != null && result.files.single.path != null) { - await _loadFile(result.files.single.path!); + if (result != null) { + await _loadFile(result); } } catch (e) { debugPrint('[Load] Error: $e'); diff --git a/lib/services/native_picker.dart b/lib/services/native_picker.dart new file mode 100644 index 0000000..e4419a2 --- /dev/null +++ b/lib/services/native_picker.dart @@ -0,0 +1,41 @@ +import 'package:flutter/services.dart'; + +/// Native file picker that shows hidden files/folders on macOS. +/// Uses a custom method channel to NSOpenPanel with showsHiddenFiles = true, +/// which the file_picker package hardcodes to false. +class NativePicker { + static const _channel = MethodChannel('com.svrnty.native_picker'); + + /// Opens a folder picker that shows hidden directories. + /// Returns the selected folder path, or null if cancelled. + static Future getDirectoryPath({String? initialDirectory}) async { + try { + return _channel.invokeMethod('getDirectoryPath', { + 'initialDirectory': initialDirectory, + }); + } on PlatformException catch (e) { + throw Exception('Native picker error: ${e.message}'); + } + } + + /// Opens a file picker that shows hidden files, filtered to specific extensions. + /// Returns the selected file path, or null if cancelled. + static Future pickFile({ + List allowedExtensions = const [], + String? initialDirectory, + }) async { + try { + final result = await _channel.invokeMethod('pickFiles', { + 'allowMultiple': false, + 'allowedExtensions': allowedExtensions, + 'initialDirectory': initialDirectory, + }); + if (result != null && result.isNotEmpty) { + return result.first as String; + } + return null; + } on PlatformException catch (e) { + throw Exception('Native picker error: ${e.message}'); + } + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ef7ac05..052e1c0 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + AABB001B1B1B1B1B1B1B /* NativePickerRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABB001A1A1A1A1A1A1A /* NativePickerRegistrar.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC10FF2044A3C60003C045 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */; }; @@ -68,6 +69,7 @@ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; 33CC10ED2044A3C60003C045 /* Claude Session Analysis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Claude Session Analysis.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AABB001A1A1A1A1A1A1A /* NativePickerRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePickerRegistrar.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; @@ -168,6 +170,7 @@ isa = PBXGroup; children = ( 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + AABB001A1A1A1A1A1A1A /* NativePickerRegistrar.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, 33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, @@ -352,6 +355,7 @@ files = ( 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + AABB001B1B1B1B1B1B1B /* NativePickerRegistrar.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c1761..8087880 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -10,4 +10,13 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + override func applicationDidFinishLaunching(_ notification: Notification) { + // Register our custom native picker that shows hidden files + // This is called before Flutter engine starts + if let controller = mainFlutterWindow?.contentViewController as? FlutterViewController { + NativePickerRegistrar.register(with: controller.registrar(forPlugin: "NativePickerRegistrar")) + } + super.applicationDidFinishLaunching(notification) + } } diff --git a/macos/Runner/NativePickerRegistrar.swift b/macos/Runner/NativePickerRegistrar.swift new file mode 100644 index 0000000..5e7921a --- /dev/null +++ b/macos/Runner/NativePickerRegistrar.swift @@ -0,0 +1,84 @@ +import Cocoa +import FlutterMacOS +import UniformTypeIdentifiers + +/// Registers a method channel for native file picking with hidden files visible. +/// Called from Dart via `ensureInitialized`. +class NativePickerRegistrar: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.svrnty.native_picker", + binaryMessenger: registrar.messenger) + let instance = NativePickerRegistrar() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getDirectoryPath": + let args = call.arguments as? [String: Any] ?? [:] + let dialog = NSOpenPanel() + + if let initial = args["initialDirectory"] as? String, !initial.isEmpty { + dialog.directoryURL = URL(fileURLWithPath: initial) + } + dialog.showsHiddenFiles = true + dialog.canChooseDirectories = true + dialog.canChooseFiles = false + dialog.allowsMultipleSelection = false + dialog.treatsFilePackagesAsDirectories = true + dialog.message = "Select a folder containing session files" + + guard let window = NSApp.keyWindow else { + result(FlutterError(code: "NO_WINDOW", message: "No key window found", details: nil)) + return + } + + dialog.beginSheetModal(for: window) { response in + if response == .OK, let url = dialog.url { + result(url.path) + } else { + result(nil) + } + } + + case "pickFiles": + let args = call.arguments as? [String: Any] ?? [:] + let dialog = NSOpenPanel() + + if let initial = args["initialDirectory"] as? String, !initial.isEmpty { + dialog.directoryURL = URL(fileURLWithPath: initial) + } + dialog.showsHiddenFiles = true + dialog.canChooseDirectories = false + dialog.canChooseFiles = true + dialog.allowsMultipleSelection = false + + if let extensions = args["allowedExtensions"] as? [String], !extensions.isEmpty { + if #available(macOS 11.0, *) { + let contentTypes = extensions.compactMap { UTType(filenameExtension: $0) } + dialog.allowedContentTypes = contentTypes + } else { + dialog.allowedFileTypes = extensions + } + } + dialog.message = "Select a session file" + + guard let window = NSApp.keyWindow else { + result(FlutterError(code: "NO_WINDOW", message: "No key window found", details: nil)) + return + } + + dialog.beginSheetModal(for: window) { response in + if response == .OK, let url = dialog.url { + result([url.path]) + } else { + result(nil) + } + } + + default: + result(FlutterMethodNotImplemented) + } + } +}