12 changed files with 673 additions and 295 deletions
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'text_editor_controller.dart'; |
||||
|
||||
class TextEditorActions { |
||||
static Future<void> openFile(BuildContext context, TextEditorController controller) async { |
||||
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false); |
||||
|
||||
if (result != null && result.files.single.path != null) { |
||||
await _loadFile(context, controller, result.files.single.path!); |
||||
} |
||||
} |
||||
|
||||
static Future<void> copyToClipboard(BuildContext context, String content) async { |
||||
await Clipboard.setData(ClipboardData(text: content)); |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制到剪贴板'))); |
||||
} |
||||
} |
||||
|
||||
static Future<void> saveFile(BuildContext context, String content) async { |
||||
try { |
||||
String? outputPath = await FilePicker.platform.saveFile( |
||||
dialogTitle: '保存文件', |
||||
fileName: 'untitled.txt', |
||||
allowedExtensions: ['txt'], |
||||
type: FileType.any, |
||||
); |
||||
|
||||
if (outputPath == null) return; |
||||
|
||||
final file = File(outputPath); |
||||
|
||||
if (await file.exists()) { |
||||
final shouldOverwrite = await showDialog<bool>( |
||||
context: context, |
||||
builder: |
||||
(context) => AlertDialog( |
||||
title: const Text('文件已存在'), |
||||
content: const Text('要覆盖现有文件吗?'), |
||||
actions: [ |
||||
TextButton( |
||||
onPressed: () => Navigator.pop(context, false), |
||||
child: const Text('取消'), |
||||
), |
||||
TextButton( |
||||
onPressed: () => Navigator.pop(context, true), |
||||
child: const Text('覆盖'), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
|
||||
if (shouldOverwrite != true) return; |
||||
} |
||||
|
||||
await file.writeAsString(content); |
||||
|
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存到: ${file.path}'))); |
||||
} |
||||
} on FileSystemException catch (e) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.message}'))); |
||||
} |
||||
} catch (e) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of( |
||||
context, |
||||
).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}'))); |
||||
} |
||||
} |
||||
} |
||||
|
||||
static Future<void> _loadFile( |
||||
BuildContext context, |
||||
TextEditorController controller, |
||||
String filePath, |
||||
) async { |
||||
try { |
||||
controller.setLoading(true); |
||||
final file = File(filePath); |
||||
final fileSize = await file.length(); |
||||
|
||||
if (fileSize > controller.maxFileSize) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of( |
||||
context, |
||||
).showSnackBar(const SnackBar(content: Text('文件过大(超过1MB),无法处理'))); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (!controller.isEmpty) { |
||||
final confirm = await showDialog<bool>( |
||||
context: context, |
||||
builder: |
||||
(context) => AlertDialog( |
||||
title: const Text('确认提示'), |
||||
content: const Text('确认是否打开新的文件?打开后将覆盖当前内容'), |
||||
actions: [ |
||||
TextButton( |
||||
onPressed: () => Navigator.pop(context, false), |
||||
child: const Text('取消'), |
||||
), |
||||
TextButton( |
||||
onPressed: () => Navigator.pop(context, true), |
||||
child: const Text('确认'), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
|
||||
if (confirm != true) return; |
||||
} |
||||
|
||||
final fileName = file.path.split('\\').last; |
||||
controller.updateContent(''); |
||||
controller.onContentChanged?.call('', fileName); |
||||
|
||||
final stream = file.openRead(); |
||||
final lines = stream.transform(utf8.decoder).transform(const LineSplitter()); |
||||
|
||||
await for (final line in lines) { |
||||
if (!controller.textController.hasListeners) break; |
||||
|
||||
controller.textController.text += '$line\n'; |
||||
controller.onContentChanged?.call(controller.content, fileName); |
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
controller.scrollController.jumpTo(controller.scrollController.position.maxScrollExtent); |
||||
}); |
||||
|
||||
await Future.delayed(const Duration(milliseconds: 10)); |
||||
} |
||||
|
||||
controller.onFileLoaded?.call(file.path); |
||||
|
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已加载: ${file.path}'))); |
||||
} |
||||
} on FormatException { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('这不是可读的文本文件'))); |
||||
} |
||||
} on FileSystemException catch (e) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('文件访问错误: ${e.message}'))); |
||||
} |
||||
} catch (e) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of( |
||||
context, |
||||
).showSnackBar(SnackBar(content: Text('读取失败: ${e.toString()}'))); |
||||
} |
||||
} finally { |
||||
controller.setLoading(false); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class TextEditorController { |
||||
final TextEditingController textController; |
||||
final FocusNode focusNode; |
||||
final ScrollController scrollController; |
||||
final Function(String, String?)? onContentChanged; |
||||
final Function(String)? onFileLoaded; |
||||
|
||||
bool _isLoading = false; |
||||
static const int _maxFileSize = 1024 * 1024; // 1MB |
||||
|
||||
TextEditorController({String? initialContent, this.onContentChanged, this.onFileLoaded}) |
||||
: textController = TextEditingController(text: initialContent ?? ''), |
||||
focusNode = FocusNode(), |
||||
scrollController = ScrollController(); |
||||
|
||||
bool get isLoading => _isLoading; |
||||
bool get hasFocus => focusNode.hasFocus; |
||||
bool get isEmpty => textController.text.isEmpty; |
||||
String get content => textController.text; |
||||
int get contentLength => textController.text.length; |
||||
int get maxFileSize => _maxFileSize; |
||||
|
||||
void updateContent(String content) { |
||||
textController.text = content; |
||||
} |
||||
|
||||
void handleContentChanged(String text) { |
||||
onContentChanged?.call(text, null); |
||||
} |
||||
|
||||
void setLoading(bool loading) { |
||||
_isLoading = loading; |
||||
} |
||||
|
||||
void dispose() { |
||||
textController.dispose(); |
||||
focusNode.dispose(); |
||||
scrollController.dispose(); |
||||
} |
||||
} |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/core/tab_manager.dart'; |
||||
|
||||
class ContentSearchController { |
||||
final TabManager tabManager; |
||||
|
||||
String searchQuery = ''; |
||||
String searchDirectory = ''; |
||||
String fileType = '*.*'; |
||||
bool caseSensitive = false; |
||||
bool wholeWord = false; |
||||
bool useRegex = false; |
||||
SearchMode searchMode = SearchMode.locate; |
||||
final List<SearchResult> results = []; |
||||
|
||||
ContentSearchController({required this.tabManager}); |
||||
|
||||
Future<void> startSearch() async { |
||||
results.clear(); |
||||
// 模拟搜索结果 |
||||
results.addAll([ |
||||
SearchResult( |
||||
filePath: 'lib/main.dart', |
||||
lineNumber: 42, |
||||
lineContent: 'void main() => runApp(MyApp());', |
||||
matches: const [MatchResult(start: 5, end: 9)], |
||||
), |
||||
SearchResult( |
||||
filePath: 'lib/home_page.dart', |
||||
lineNumber: 17, |
||||
lineContent: 'class HomePage extends StatelessWidget {', |
||||
matches: const [MatchResult(start: 6, end: 10)], |
||||
), |
||||
]); |
||||
} |
||||
|
||||
Future<void> pickDirectory() async { |
||||
final dir = await FilePicker.platform.getDirectoryPath(); |
||||
if (dir != null) { |
||||
searchDirectory = dir; |
||||
} |
||||
} |
||||
} |
||||
|
||||
enum SearchMode { locate, count } |
||||
|
||||
class SearchResult { |
||||
final String filePath; |
||||
final int lineNumber; |
||||
final String lineContent; |
||||
final List<MatchResult> matches; |
||||
|
||||
SearchResult({ |
||||
required this.filePath, |
||||
required this.lineNumber, |
||||
required this.lineContent, |
||||
required this.matches, |
||||
}); |
||||
} |
||||
|
||||
class MatchResult { |
||||
final int start; |
||||
final int end; |
||||
|
||||
const MatchResult({required this.start, required this.end}); |
||||
} |
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/app/core/tab_manager.dart'; |
||||
import 'content_search_controller.dart'; |
||||
import 'directory_settings.dart'; |
||||
import 'search_settings.dart'; |
||||
import 'results_view.dart'; |
||||
|
||||
class ContentSearchView extends StatefulWidget { |
||||
final String tabId; |
||||
|
||||
const ContentSearchView({super.key, required this.tabId}); |
||||
|
||||
@override |
||||
State<ContentSearchView> createState() => ContentSearchViewState(); |
||||
} |
||||
|
||||
class ContentSearchViewState extends State<ContentSearchView> { |
||||
late final ContentSearchController _controller; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_controller = ContentSearchController( |
||||
tabManager: Provider.of<TabManager>(context, listen: false), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Column( |
||||
children: [ |
||||
DirectorySettings(controller: _controller), |
||||
const SizedBox(height: 16), |
||||
SearchSettings(controller: _controller), |
||||
const SizedBox(height: 16), |
||||
Expanded(child: ResultsView(controller: _controller)), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/modules/content_search/content_search_controller.dart'; |
||||
|
||||
class DirectorySettings extends StatelessWidget { |
||||
final ContentSearchController controller; |
||||
|
||||
const DirectorySettings({super.key, required this.controller}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Card( |
||||
child: Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Row( |
||||
children: [ |
||||
const Icon(Icons.folder, color: Colors.blue), |
||||
const SizedBox(width: 8), |
||||
Expanded( |
||||
child: TextField( |
||||
decoration: const InputDecoration(labelText: '搜索目录', border: OutlineInputBorder()), |
||||
onChanged: (value) => controller.searchDirectory = value, |
||||
), |
||||
), |
||||
const SizedBox(width: 8), |
||||
SizedBox( |
||||
width: 100, |
||||
child: TextField( |
||||
decoration: const InputDecoration( |
||||
labelText: '文件类型', |
||||
border: OutlineInputBorder(), |
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), |
||||
), |
||||
controller: TextEditingController(text: controller.fileType), |
||||
onChanged: (value) => controller.fileType = value, |
||||
), |
||||
), |
||||
const SizedBox(width: 8), |
||||
IconButton(icon: const Icon(Icons.folder_open), onPressed: controller.pickDirectory), |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/modules/content_search/content_search_controller.dart'; |
||||
|
||||
class ResultsView extends StatelessWidget { |
||||
final ContentSearchController controller; |
||||
|
||||
const ResultsView({super.key, required this.controller}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Card( |
||||
child: Column( |
||||
children: [ |
||||
Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Text( |
||||
controller.searchMode == SearchMode.locate ? '定位结果' : '计数结果', |
||||
style: Theme.of(context).textTheme.titleMedium, |
||||
), |
||||
), |
||||
Expanded( |
||||
child: |
||||
controller.searchMode == SearchMode.locate |
||||
? _buildLocateResults() |
||||
: _buildCountResults(), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildLocateResults() { |
||||
return ListView.builder( |
||||
itemCount: controller.results.length, |
||||
itemBuilder: (ctx, index) { |
||||
final result = controller.results[index]; |
||||
return ExpansionTile( |
||||
title: Text('${result.filePath}:${result.lineNumber}'), |
||||
children: [ |
||||
Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Text.rich(_buildHighlightedText(result.lineContent, result.matches)), |
||||
), |
||||
], |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
|
||||
Widget _buildCountResults() { |
||||
final counts = <String, int>{}; |
||||
for (var r in controller.results) { |
||||
counts[r.filePath] = (counts[r.filePath] ?? 0) + r.matches.length; |
||||
} |
||||
|
||||
return ListView.builder( |
||||
itemCount: counts.length, |
||||
itemBuilder: (ctx, index) { |
||||
final entry = counts.entries.elementAt(index); |
||||
return ListTile( |
||||
leading: const Icon(Icons.insert_drive_file), |
||||
title: Text(entry.key), |
||||
trailing: Chip(label: Text('${entry.value}处')), |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
|
||||
TextSpan _buildHighlightedText(String text, List<MatchResult> matches) { |
||||
final spans = <TextSpan>[]; |
||||
int lastEnd = 0; |
||||
|
||||
for (final match in matches) { |
||||
if (match.start > lastEnd) { |
||||
spans.add(TextSpan(text: text.substring(lastEnd, match.start))); |
||||
} |
||||
spans.add( |
||||
TextSpan( |
||||
text: text.substring(match.start, match.end), |
||||
style: const TextStyle(backgroundColor: Colors.yellow, fontWeight: FontWeight.bold), |
||||
), |
||||
); |
||||
lastEnd = match.end; |
||||
} |
||||
if (lastEnd < text.length) { |
||||
spans.add(TextSpan(text: text.substring(lastEnd))); |
||||
} |
||||
|
||||
return TextSpan(children: spans); |
||||
} |
||||
} |
@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/components/text_editor.dart'; |
||||
import 'package:win_text_editor/app/modules/content_search/content_search_controller.dart'; |
||||
|
||||
class SearchSettings extends StatelessWidget { |
||||
final ContentSearchController controller; |
||||
final GlobalKey<TextEditorState> _searchEditorKey = GlobalKey(); |
||||
|
||||
SearchSettings({super.key, required this.controller}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Card( |
||||
child: Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Row( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
// 搜索内容框 |
||||
SizedBox( |
||||
width: MediaQuery.of(context).size.width * 0.5, // 占据一半宽度 |
||||
height: 300, |
||||
child: TextEditor( |
||||
key: _searchEditorKey, |
||||
tabId: 'search_content', |
||||
title: '搜索内容', |
||||
onContentChanged: (content, _) => controller.searchQuery = content, |
||||
), |
||||
), |
||||
const SizedBox(width: 8), |
||||
// 设置按钮区域 |
||||
Expanded( |
||||
child: Container( |
||||
decoration: BoxDecoration( |
||||
border: Border.all(color: Colors.grey), // 设置边框颜色 |
||||
borderRadius: BorderRadius.circular(4), // 设置边框圆角 |
||||
), |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
// 搜索方式 |
||||
Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
const Text('搜索方式:', style: TextStyle(fontSize: 12)), |
||||
Row( |
||||
children: [ |
||||
Radio<SearchMode>( |
||||
value: SearchMode.locate, |
||||
groupValue: controller.searchMode, |
||||
onChanged: (value) => controller.searchMode = value!, |
||||
), |
||||
const Text('定位', style: TextStyle(fontSize: 12)), |
||||
], |
||||
), |
||||
Row( |
||||
children: [ |
||||
Radio<SearchMode>( |
||||
value: SearchMode.count, |
||||
groupValue: controller.searchMode, |
||||
onChanged: (value) => controller.searchMode = value!, |
||||
), |
||||
const Text('计数', style: TextStyle(fontSize: 12)), |
||||
], |
||||
), |
||||
], |
||||
), |
||||
const SizedBox(height: 8), |
||||
// 匹配规则 |
||||
Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
const Text('匹配规则:', style: TextStyle(fontSize: 12)), |
||||
CheckboxListTile( |
||||
contentPadding: EdgeInsets.zero, |
||||
controlAffinity: ListTileControlAffinity.leading, |
||||
title: const Text('大小写敏感', style: TextStyle(fontSize: 12)), |
||||
value: controller.caseSensitive, |
||||
onChanged: (value) => controller.caseSensitive = value!, |
||||
), |
||||
CheckboxListTile( |
||||
contentPadding: EdgeInsets.zero, |
||||
controlAffinity: ListTileControlAffinity.leading, |
||||
title: const Text('全字匹配', style: TextStyle(fontSize: 12)), |
||||
value: controller.wholeWord, |
||||
onChanged: (value) => controller.wholeWord = value!, |
||||
), |
||||
CheckboxListTile( |
||||
contentPadding: EdgeInsets.zero, |
||||
controlAffinity: ListTileControlAffinity.leading, |
||||
title: const Text('正则匹配', style: TextStyle(fontSize: 12)), |
||||
value: controller.useRegex, |
||||
onChanged: (value) => controller.useRegex = value!, |
||||
), |
||||
], |
||||
), |
||||
const SizedBox(height: 8), |
||||
// 开始搜索按钮 |
||||
Align( |
||||
alignment: Alignment.centerLeft, |
||||
child: ElevatedButton.icon( |
||||
icon: const Icon(Icons.search, size: 20), |
||||
label: const Text('开始搜索'), |
||||
onPressed: controller.startSearch, |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
Loading…
Reference in new issue