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
This commit is contained in:
parent
0b72c679bc
commit
780ef9378f
@ -22,8 +22,8 @@ class ContentBlock {
|
|||||||
class TextBlock extends ContentBlock {
|
class TextBlock extends ContentBlock {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
TextBlock({required this.text, required Map<String, dynamic> raw})
|
TextBlock({required this.text, required super.raw})
|
||||||
: super(type: 'text', raw: raw);
|
: super(type: 'text');
|
||||||
|
|
||||||
factory TextBlock.fromJson(Map<String, dynamic> json) {
|
factory TextBlock.fromJson(Map<String, dynamic> json) {
|
||||||
return TextBlock(
|
return TextBlock(
|
||||||
@ -40,8 +40,8 @@ class ThinkingBlock extends ContentBlock {
|
|||||||
ThinkingBlock({
|
ThinkingBlock({
|
||||||
required this.thinking,
|
required this.thinking,
|
||||||
this.signature,
|
this.signature,
|
||||||
required Map<String, dynamic> raw,
|
required super.raw,
|
||||||
}) : super(type: 'thinking', raw: raw);
|
}) : super(type: 'thinking');
|
||||||
|
|
||||||
factory ThinkingBlock.fromJson(Map<String, dynamic> json) {
|
factory ThinkingBlock.fromJson(Map<String, dynamic> json) {
|
||||||
return ThinkingBlock(
|
return ThinkingBlock(
|
||||||
@ -63,8 +63,8 @@ class ToolUseBlock extends ContentBlock {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.input,
|
required this.input,
|
||||||
this.linkedResult,
|
this.linkedResult,
|
||||||
required Map<String, dynamic> raw,
|
required super.raw,
|
||||||
}) : super(type: 'tool_use', raw: raw);
|
}) : super(type: 'tool_use');
|
||||||
|
|
||||||
factory ToolUseBlock.fromJson(Map<String, dynamic> json) {
|
factory ToolUseBlock.fromJson(Map<String, dynamic> json) {
|
||||||
return ToolUseBlock(
|
return ToolUseBlock(
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../providers/session_provider.dart';
|
import '../../providers/session_provider.dart';
|
||||||
|
import '../../services/native_picker.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
|
||||||
// ─── Data models ─────────────────────────────────────────────
|
// ─── Data models ─────────────────────────────────────────────
|
||||||
@ -120,7 +120,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the real user home directory, even inside App Sandbox.
|
/// Returns the real user home directory, even inside App Sandbox.
|
||||||
/// In sandbox, HOME points to ~/Library/Containers/<bundleid>/Data.
|
/// In sandbox, HOME points to `~/Library/Containers/bundleid/Data`.
|
||||||
String? _getRealHome() {
|
String? _getRealHome() {
|
||||||
final home = Platform.environment['HOME'];
|
final home = Platform.environment['HOME'];
|
||||||
if (home == null) return null;
|
if (home == null) return null;
|
||||||
@ -333,8 +333,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
Future<void> _pickFolder() async {
|
Future<void> _pickFolder() async {
|
||||||
try {
|
try {
|
||||||
final result = await FilePicker.platform.getDirectoryPath(
|
final home = _getRealHome() ?? '';
|
||||||
dialogTitle: 'Select a folder containing Claude session files',
|
final result = await NativePicker.getDirectoryPath(
|
||||||
|
initialDirectory: '$home/.claude/projects',
|
||||||
);
|
);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
_pathController.text = result;
|
_pathController.text = result;
|
||||||
@ -407,13 +408,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
Future<void> _loadSingleFile() async {
|
Future<void> _loadSingleFile() async {
|
||||||
try {
|
try {
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final home = _getRealHome() ?? '';
|
||||||
type: FileType.custom,
|
final result = await NativePicker.pickFile(
|
||||||
allowedExtensions: ['jsonl'],
|
allowedExtensions: ['jsonl'],
|
||||||
dialogTitle: 'Select a Claude session file (.jsonl)',
|
initialDirectory: '$home/.claude/projects',
|
||||||
);
|
);
|
||||||
if (result != null && result.files.single.path != null) {
|
if (result != null) {
|
||||||
await _loadFile(result.files.single.path!);
|
await _loadFile(result);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Load] Error: $e');
|
debugPrint('[Load] Error: $e');
|
||||||
|
|||||||
41
lib/services/native_picker.dart
Normal file
41
lib/services/native_picker.dart
Normal file
@ -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<String?> getDirectoryPath({String? initialDirectory}) async {
|
||||||
|
try {
|
||||||
|
return _channel.invokeMethod<String?>('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<String?> pickFile({
|
||||||
|
List<String> allowedExtensions = const [],
|
||||||
|
String? initialDirectory,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod<List?>('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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@
|
|||||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.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 */; };
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC10FF2044A3C60003C045 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */; };
|
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 = "<group>"; };
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
33CC10ED2044A3C60003C045 /* Claude Session Analysis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Claude Session Analysis.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
AABB001A1A1A1A1A1A1A /* NativePickerRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePickerRegistrar.swift; sourceTree = "<group>"; };
|
||||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||||
@ -168,6 +170,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
|
||||||
|
AABB001A1A1A1A1A1A1A /* NativePickerRegistrar.swift */,
|
||||||
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
|
||||||
33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */,
|
33CC10FE2044A3C60003C045 /* PrivacyInfo.xcprivacy */,
|
||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
|
||||||
@ -352,6 +355,7 @@
|
|||||||
files = (
|
files = (
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
|
||||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||||
|
AABB001B1B1B1B1B1B1B /* NativePickerRegistrar.swift in Sources */,
|
||||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@ -10,4 +10,13 @@ class AppDelegate: FlutterAppDelegate {
|
|||||||
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
|
||||||
return true
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
macos/Runner/NativePickerRegistrar.swift
Normal file
84
macos/Runner/NativePickerRegistrar.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user