18 changed files with 1283 additions and 431 deletions
@ -0,0 +1,106 @@ |
|||||||
|
import 'package:syncfusion_flutter_datagrid/datagrid.dart'; |
||||||
|
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||||
|
import 'package:win_text_editor/framework/services/macro_template_service.dart'; |
||||||
|
import 'package:win_text_editor/modules/call_function/models/call_function.dart'; |
||||||
|
import 'package:win_text_editor/modules/call_function/services/call_function_service.dart'; |
||||||
|
import 'package:win_text_editor/shared/models/std_filed.dart'; |
||||||
|
import 'package:win_text_editor/shared/uft_std_fields/field_data_source.dart'; |
||||||
|
import 'package:win_text_editor/shared/base/base_content_controller.dart'; |
||||||
|
|
||||||
|
class CallFunctionController extends BaseContentController { |
||||||
|
String? _errorMessage; |
||||||
|
String tableName = ""; |
||||||
|
String objectId = ""; |
||||||
|
String chineseName = ""; |
||||||
|
|
||||||
|
late DataGridSource fieldsSource; |
||||||
|
late DataGridSource indexesSource; |
||||||
|
final CallFunctionService _service; |
||||||
|
final MacroTemplateService templateService = MacroTemplateService(); |
||||||
|
|
||||||
|
// 新增:维护CallFunction对象 |
||||||
|
late CallFunction _memoryTable; |
||||||
|
|
||||||
|
CallFunctionController() : _service = CallFunctionService(Logger()) { |
||||||
|
// 初始化空数据 |
||||||
|
final initialFields = [Field('1', '', '', '', false), Field('2', '', '', '', false)]; |
||||||
|
|
||||||
|
fieldsSource = FieldsDataSource( |
||||||
|
initialFields, |
||||||
|
onSelectionChanged: (index, isSelected) { |
||||||
|
updateFieldSelection(index, isSelected); |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// 初始化CallFunction |
||||||
|
_memoryTable = CallFunction(tableName: '', columns: initialFields); |
||||||
|
} |
||||||
|
|
||||||
|
String? get errorMessage => _errorMessage; |
||||||
|
|
||||||
|
// 新增:获取当前CallFunction |
||||||
|
CallFunction get memoryTable => _memoryTable; |
||||||
|
|
||||||
|
void initTemplateService() { |
||||||
|
if (!templateService.inited) { |
||||||
|
templateService.init(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
String? genCodeString(List<String> macroList) { |
||||||
|
initTemplateService(); |
||||||
|
return templateService.renderTemplate(macroList, _memoryTable.toMap()); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Future<void> onOpenFile(String filePath) async { |
||||||
|
Logger().info("Opening file: $filePath"); |
||||||
|
try { |
||||||
|
final tableData = await _service.parseStructureFile(filePath); |
||||||
|
|
||||||
|
// Update controller state |
||||||
|
tableName = tableData.tableName; |
||||||
|
chineseName = tableData.chineseName; |
||||||
|
objectId = tableData.objectId; |
||||||
|
|
||||||
|
// Update data sources |
||||||
|
(fieldsSource as FieldsDataSource).updateData(tableData.fields); |
||||||
|
|
||||||
|
// 更新CallFunction对象 |
||||||
|
_memoryTable = CallFunction(tableName: tableName, columns: tableData.fields); |
||||||
|
|
||||||
|
// Clear any previous error |
||||||
|
_errorMessage = null; |
||||||
|
|
||||||
|
// Notify UI to update |
||||||
|
notifyListeners(); |
||||||
|
} catch (e) { |
||||||
|
_errorMessage = e.toString(); |
||||||
|
notifyListeners(); |
||||||
|
Logger().error("Error opening file: $e"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 新增:更新字段选择状态 |
||||||
|
void updateFieldSelection(int index, bool isSelected) { |
||||||
|
final fields = (fieldsSource as FieldsDataSource).data; |
||||||
|
if (index >= 0 && index < fields.length) { |
||||||
|
fields[index].isSelected = isSelected; |
||||||
|
fieldsSource.notifyListeners(); |
||||||
|
|
||||||
|
// 同步更新CallFunction |
||||||
|
_memoryTable.columns[index].isSelected = isSelected; |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void onOpenFolder(String folderPath) { |
||||||
|
// 不支持打开文件夹 |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
import 'package:win_text_editor/shared/models/std_filed.dart'; |
||||||
|
|
||||||
|
class CallFunction { |
||||||
|
final String tableName; |
||||||
|
final List<Field> columns; |
||||||
|
|
||||||
|
CallFunction({required this.tableName, required this.columns}); |
||||||
|
|
||||||
|
List<Field> get selectFields => columns.where((field) => field.isSelected).toList(); |
||||||
|
|
||||||
|
Map<String, dynamic> toMap() { |
||||||
|
return { |
||||||
|
'tableName': tableName, |
||||||
|
'fields': |
||||||
|
columns |
||||||
|
.map( |
||||||
|
(field) => { |
||||||
|
'id': field.id, |
||||||
|
'name': field.name, |
||||||
|
'chineseName': field.chineseName, |
||||||
|
'type': field.type, |
||||||
|
'isLast': columns.indexOf(field) == columns.length - 1, |
||||||
|
}, |
||||||
|
) |
||||||
|
.toList(), |
||||||
|
'selectedFields': |
||||||
|
selectFields |
||||||
|
.map( |
||||||
|
(field) => { |
||||||
|
'id': field.id, |
||||||
|
'name': field.name, |
||||||
|
'chineseName': field.chineseName, |
||||||
|
'type': field.type, |
||||||
|
'isLast': selectFields.indexOf(field) == selectFields.length - 1, |
||||||
|
}, |
||||||
|
) |
||||||
|
.toList(), |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,128 @@ |
|||||||
|
// memory_table_service.dart |
||||||
|
import 'dart:io'; |
||||||
|
|
||||||
|
import 'package:win_text_editor/modules/memory_table/models/memory_table.dart'; |
||||||
|
import 'package:win_text_editor/shared/data/std_fields_cache.dart'; |
||||||
|
import 'package:win_text_editor/shared/models/std_filed.dart'; |
||||||
|
import 'package:win_text_editor/shared/uft_std_fields/field_data_service.dart'; |
||||||
|
import 'package:xml/xml.dart' as xml; |
||||||
|
import 'package:path/path.dart' as path; |
||||||
|
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||||
|
|
||||||
|
class CallFunctionService { |
||||||
|
final Logger _logger; |
||||||
|
|
||||||
|
CallFunctionService(this._logger); |
||||||
|
|
||||||
|
Future<TableData> parseStructureFile(String filePath) async { |
||||||
|
try { |
||||||
|
// 1. Check file extension |
||||||
|
if (!filePath.toLowerCase().endsWith('.uftstructure')) { |
||||||
|
throw const FormatException("文件扩展名必须是.uftstructure"); |
||||||
|
} |
||||||
|
|
||||||
|
// 2. 查找 metadata 目录和 stdfield.stfield 文件 |
||||||
|
if (await StdFieldsCache.getLength() == 0) { |
||||||
|
_logger.info("加载标准字段缓存"); |
||||||
|
final metadataFile = await FieldDataService.findMetadataFile(filePath); |
||||||
|
if (metadataFile != null) { |
||||||
|
await FieldDataService.processStdFieldFile(metadataFile); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 3. Read and parse structure file content |
||||||
|
final file = File(filePath); |
||||||
|
final content = await file.readAsString(); |
||||||
|
|
||||||
|
final document = xml.XmlDocument.parse(content); |
||||||
|
final structureNode = document.findAllElements('structure:Structure').firstOrNull; |
||||||
|
|
||||||
|
if (structureNode == null) { |
||||||
|
throw const FormatException("文件格式错误:缺少structure:Structure节点"); |
||||||
|
} |
||||||
|
|
||||||
|
// 4. Get basic info |
||||||
|
final fileNameWithoutExt = path.basenameWithoutExtension(filePath); |
||||||
|
final chineseName = structureNode.getAttribute('chineseName') ?? ''; |
||||||
|
final objectId = structureNode.getAttribute('objectId') ?? ''; |
||||||
|
|
||||||
|
// 5. Process properties (fields) |
||||||
|
final properties = document.findAllElements('properties'); |
||||||
|
final fields = <Field>[]; |
||||||
|
int index = 1; |
||||||
|
|
||||||
|
for (final property in properties) { |
||||||
|
final id = property.getAttribute('id') ?? ''; |
||||||
|
// 尝试从缓存获取标准字段信息 |
||||||
|
final stdField = StdFieldsCache.getData(id); |
||||||
|
fields.add( |
||||||
|
Field( |
||||||
|
(index++).toString(), // 序号 |
||||||
|
id, // 名称 |
||||||
|
stdField?.chineseName ?? '', // 中文名 |
||||||
|
stdField?.dateType ?? '', // 类型 |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// 6. Process indexes |
||||||
|
final indexes = document.findAllElements('indexs'); |
||||||
|
final indexList = <Index>[]; |
||||||
|
|
||||||
|
for (final indexNode in indexes) { |
||||||
|
final name = indexNode.getAttribute('name') ?? ''; |
||||||
|
final containerType = indexNode.getAttribute('containerType'); |
||||||
|
final isPrimary = containerType == null; |
||||||
|
final rule = containerType ?? ''; |
||||||
|
|
||||||
|
// Get all index fields |
||||||
|
final items = indexNode.findAllElements('items'); |
||||||
|
final fieldsList = |
||||||
|
items |
||||||
|
.map((item) => item.getAttribute('attrname') ?? '') |
||||||
|
.where((f) => f.isNotEmpty) |
||||||
|
.toList(); |
||||||
|
final indexFields = fieldsList.join(','); |
||||||
|
|
||||||
|
indexList.add( |
||||||
|
Index( |
||||||
|
name, // 索引名称 |
||||||
|
isPrimary, // 是否主键 |
||||||
|
indexFields, // 索引字段 |
||||||
|
rule, // 规则 |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return TableData( |
||||||
|
tableName: fileNameWithoutExt, |
||||||
|
chineseName: chineseName, |
||||||
|
objectId: objectId, |
||||||
|
fields: fields.isNotEmpty ? fields : FieldDataService.getDefaultFields(), |
||||||
|
indexes: indexList.isNotEmpty ? indexList : FieldDataService.getDefaultIndexes(), |
||||||
|
); |
||||||
|
} on xml.XmlParserException catch (e) { |
||||||
|
_logger.error("XML解析错误: ${e.message}"); |
||||||
|
rethrow; |
||||||
|
} catch (e) { |
||||||
|
_logger.error("处理文件时发生错误: $e"); |
||||||
|
rethrow; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class TableData { |
||||||
|
final String tableName; |
||||||
|
final String chineseName; |
||||||
|
final String objectId; |
||||||
|
final List<Field> fields; |
||||||
|
final List<Index> indexes; |
||||||
|
|
||||||
|
TableData({ |
||||||
|
required this.tableName, |
||||||
|
required this.chineseName, |
||||||
|
required this.objectId, |
||||||
|
required this.fields, |
||||||
|
required this.indexes, |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:win_text_editor/modules/call_function/controllers/call_function_controller.dart'; |
||||||
|
import 'package:win_text_editor/shared/uft_std_fields/field_data_source.dart'; |
||||||
|
import 'package:win_text_editor/shared/uft_std_fields/fields_data_grid.dart'; |
||||||
|
|
||||||
|
class CallFunctionLeftSide extends StatelessWidget { |
||||||
|
final CallFunctionController controller; |
||||||
|
const CallFunctionLeftSide({super.key, required this.controller}); |
||||||
|
|
||||||
|
Widget _buildTextFieldRow(String label, String value) { |
||||||
|
return Row( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
Text('$label:'), |
||||||
|
SizedBox( |
||||||
|
width: 200, |
||||||
|
child: TextField( |
||||||
|
controller: TextEditingController(text: value), |
||||||
|
readOnly: true, |
||||||
|
decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)), |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
SizedBox( |
||||||
|
width: double.infinity, |
||||||
|
child: Card( |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.all(8.0), |
||||||
|
child: Wrap( |
||||||
|
spacing: 16, |
||||||
|
runSpacing: 8, |
||||||
|
children: [ |
||||||
|
_buildTextFieldRow('名称', controller.tableName), |
||||||
|
_buildTextFieldRow('中文名', controller.chineseName), |
||||||
|
_buildTextFieldRow('对象编号', controller.objectId), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(height: 8), |
||||||
|
const Padding( |
||||||
|
padding: EdgeInsets.all(8.0), |
||||||
|
child: Text('字段列表', style: TextStyle(fontWeight: FontWeight.bold)), |
||||||
|
), |
||||||
|
Expanded( |
||||||
|
flex: 6, |
||||||
|
child: FieldsDataGrid( |
||||||
|
fieldsSource: controller.fieldsSource as FieldsDataSource, |
||||||
|
onSelectionChanged: (index, isSelected) { |
||||||
|
controller.updateFieldSelection(index, isSelected); |
||||||
|
}, |
||||||
|
), |
||||||
|
), |
||||||
|
const Padding( |
||||||
|
padding: EdgeInsets.all(8.0), |
||||||
|
child: Text('索引列表', style: TextStyle(fontWeight: FontWeight.bold)), |
||||||
|
), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,136 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter/services.dart'; |
||||||
|
import 'package:win_text_editor/modules/call_function/controllers/call_function_controller.dart'; |
||||||
|
|
||||||
|
class CallFunctionRightSide extends StatefulWidget { |
||||||
|
final CallFunctionController controller; |
||||||
|
final TextEditingController codeController; |
||||||
|
|
||||||
|
const CallFunctionRightSide({super.key, required this.controller, required this.codeController}); |
||||||
|
|
||||||
|
@override |
||||||
|
State<CallFunctionRightSide> createState() => _CallFunctionRightSideState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _CallFunctionRightSideState extends State<CallFunctionRightSide> { |
||||||
|
final List<String> _selectedOperations = []; |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
widget.controller.initTemplateService(); |
||||||
|
widget.controller.addListener(_updateDisplay); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
widget.controller.removeListener(_updateDisplay); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
void _updateDisplay() { |
||||||
|
widget.codeController.text = widget.controller.genCodeString(_selectedOperations)!; |
||||||
|
} |
||||||
|
|
||||||
|
void _toggleOperation(String operation, bool? value) { |
||||||
|
setState(() { |
||||||
|
if (value == true) { |
||||||
|
_selectedOperations.add(operation); |
||||||
|
} else { |
||||||
|
_selectedOperations.remove(operation); |
||||||
|
} |
||||||
|
_updateDisplay(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Column( |
||||||
|
children: [ |
||||||
|
_buildCheckboxSection(), |
||||||
|
Padding( |
||||||
|
padding: const EdgeInsets.only(left: 8.0, right: 8.0), |
||||||
|
child: Row( |
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||||
|
children: [ |
||||||
|
const Text('生成代码:', style: TextStyle(fontWeight: FontWeight.bold)), |
||||||
|
IconButton( |
||||||
|
icon: const Icon(Icons.content_copy, size: 20), |
||||||
|
tooltip: '复制代码', |
||||||
|
onPressed: () { |
||||||
|
if (widget.codeController.text.isNotEmpty) { |
||||||
|
Clipboard.setData(ClipboardData(text: widget.codeController.text)); |
||||||
|
ScaffoldMessenger.of( |
||||||
|
context, |
||||||
|
).showSnackBar(const SnackBar(content: Text('已复制到剪贴板'))); |
||||||
|
} |
||||||
|
}, |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
Flexible(child: _buildCodeEditor()), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Widget _buildCheckboxSection() { |
||||||
|
final operations = ['获取记录', '获取记录数', '插入记录', '修改记录', '删除记录', '遍历记录']; |
||||||
|
|
||||||
|
return SizedBox( |
||||||
|
width: double.infinity, |
||||||
|
child: Card( |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.all(8.0), |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
Wrap( |
||||||
|
spacing: 16, |
||||||
|
runSpacing: 8, |
||||||
|
children: operations.map((op) => _buildCheckbox(op)).toList(), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Widget _buildCheckbox(String label) { |
||||||
|
return Row( |
||||||
|
mainAxisSize: MainAxisSize.min, |
||||||
|
children: [ |
||||||
|
Checkbox( |
||||||
|
value: _selectedOperations.contains(label), |
||||||
|
onChanged: (bool? value) => _toggleOperation(label, value), |
||||||
|
), |
||||||
|
Text(label), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Widget _buildCodeEditor() { |
||||||
|
return Card( |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.all(8), |
||||||
|
child: Container( |
||||||
|
decoration: BoxDecoration( |
||||||
|
border: Border.all(color: Colors.grey), |
||||||
|
borderRadius: BorderRadius.circular(4), |
||||||
|
), |
||||||
|
child: TextField( |
||||||
|
controller: widget.codeController, |
||||||
|
maxLines: null, |
||||||
|
expands: true, |
||||||
|
decoration: const InputDecoration( |
||||||
|
border: InputBorder.none, |
||||||
|
contentPadding: EdgeInsets.all(8), |
||||||
|
), |
||||||
|
style: const TextStyle(fontFamily: 'monospace', color: Colors.blueAccent), |
||||||
|
), |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,76 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
import 'package:win_text_editor/framework/controllers/tab_items_controller.dart'; |
||||||
|
import 'package:win_text_editor/modules/call_function/controllers/call_function_controller.dart'; |
||||||
|
|
||||||
|
import 'call_function_left_side.dart'; |
||||||
|
import 'call_function_right_side.dart'; |
||||||
|
|
||||||
|
class CallFunctionView extends StatefulWidget { |
||||||
|
final String tabId; |
||||||
|
const CallFunctionView({super.key, required this.tabId}); |
||||||
|
|
||||||
|
@override |
||||||
|
State<CallFunctionView> createState() => _CallFunctionViewState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _CallFunctionViewState extends State<CallFunctionView> { |
||||||
|
late final CallFunctionController _controller; |
||||||
|
final TextEditingController _codeController = TextEditingController(); |
||||||
|
bool _isControllerFromTabManager = false; |
||||||
|
|
||||||
|
get tabManager => Provider.of<TabItemsController>(context, listen: false); |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
|
||||||
|
final controllerFromManager = tabManager.getController(widget.tabId); |
||||||
|
if (controllerFromManager != null) { |
||||||
|
_controller = controllerFromManager; |
||||||
|
_isControllerFromTabManager = true; |
||||||
|
} else { |
||||||
|
_controller = CallFunctionController(); |
||||||
|
_isControllerFromTabManager = false; |
||||||
|
tabManager.registerController(widget.tabId, _controller); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
if (!_isControllerFromTabManager) { |
||||||
|
_controller.dispose(); |
||||||
|
} |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return ChangeNotifierProvider.value( |
||||||
|
value: _controller, |
||||||
|
child: Consumer<CallFunctionController>( |
||||||
|
builder: (context, controller, child) { |
||||||
|
return Padding( |
||||||
|
padding: const EdgeInsets.all(8.0), |
||||||
|
child: Row( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
// 左侧部分 (50%) |
||||||
|
Expanded(flex: 5, child: CallFunctionLeftSide(controller: controller)), |
||||||
|
const SizedBox(width: 8), |
||||||
|
// 右侧部分 (50%) |
||||||
|
Expanded( |
||||||
|
flex: 5, |
||||||
|
child: CallFunctionRightSide( |
||||||
|
codeController: _codeController, |
||||||
|
controller: controller, |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
}, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,153 @@ |
|||||||
|
// lib/app/modules/content_search/services/base_search_service.dart |
||||||
|
import 'dart:io'; |
||||||
|
import 'package:path/path.dart' as path; |
||||||
|
|
||||||
|
typedef ProgressCallback = void Function(double progress); |
||||||
|
typedef FileProcessor = Future<void> Function(File file, String content); |
||||||
|
|
||||||
|
abstract class BaseSearchService { |
||||||
|
/// 处理文件遍历和进度回调(核心复用逻辑) |
||||||
|
static Future<void> processFiles({ |
||||||
|
required String directory, |
||||||
|
required String fileType, |
||||||
|
required FileProcessor onFile, |
||||||
|
ProgressCallback? onProgress, |
||||||
|
bool Function()? shouldStop, |
||||||
|
}) async { |
||||||
|
final dir = Directory(directory); |
||||||
|
final totalFiles = await countFilesInDirectory(dir, fileType, shouldStop); |
||||||
|
if (totalFiles == 0) return; |
||||||
|
|
||||||
|
onProgress?.call(1); |
||||||
|
|
||||||
|
int processedFiles = 0; |
||||||
|
int oldProgress = 1; |
||||||
|
|
||||||
|
await for (final entity in dir.list(recursive: true)) { |
||||||
|
if (shouldStop?.call() == true) return; |
||||||
|
if (entity is! File || !matchesFileType(entity.path, fileType)) continue; |
||||||
|
|
||||||
|
processedFiles++; |
||||||
|
final progress = (processedFiles / totalFiles) * 99 + 1; |
||||||
|
if (progress - oldProgress >= 1) { |
||||||
|
oldProgress = progress.floor(); |
||||||
|
onProgress?.call(progress); |
||||||
|
} |
||||||
|
|
||||||
|
final content = await entity.readAsString(); |
||||||
|
await onFile(entity, content); |
||||||
|
} |
||||||
|
|
||||||
|
onProgress?.call(100); |
||||||
|
} |
||||||
|
|
||||||
|
/// 以下为静态工具方法(被所有服务共享) |
||||||
|
static List<String> splitQuery(String query) { |
||||||
|
return query.split(',').map((q) => q.trim()).where((q) => q.isNotEmpty).toList(); |
||||||
|
} |
||||||
|
|
||||||
|
static RegExp buildSearchPattern({ |
||||||
|
required String query, |
||||||
|
required bool caseSensitive, |
||||||
|
required bool wholeWord, |
||||||
|
required bool useRegex, |
||||||
|
}) { |
||||||
|
String pattern; |
||||||
|
if (useRegex) { |
||||||
|
pattern = query; |
||||||
|
} else { |
||||||
|
pattern = RegExp.escape(query); |
||||||
|
if (wholeWord) { |
||||||
|
pattern = '\\b$pattern\\b'; |
||||||
|
} |
||||||
|
} |
||||||
|
return RegExp(pattern, caseSensitive: caseSensitive, multiLine: true); |
||||||
|
} |
||||||
|
|
||||||
|
static Future<int> countFilesInDirectory( |
||||||
|
Directory dir, |
||||||
|
String fileType, |
||||||
|
bool Function()? shouldStop, |
||||||
|
) async { |
||||||
|
int totalFiles = 0; |
||||||
|
await for (final entity in dir.list(recursive: true)) { |
||||||
|
if (shouldStop?.call() == true) return totalFiles; |
||||||
|
if (entity is File && matchesFileType(entity.path, fileType)) { |
||||||
|
totalFiles++; |
||||||
|
} |
||||||
|
} |
||||||
|
return totalFiles; |
||||||
|
} |
||||||
|
|
||||||
|
static bool matchesFileType(String filePath, String fileType) { |
||||||
|
// 处理特殊情况 |
||||||
|
if (fileType == '*.*') return true; |
||||||
|
if (fileType == '*') return true; |
||||||
|
|
||||||
|
// 分割通配符为文件名和扩展名部分 |
||||||
|
final parts = fileType.split('.'); |
||||||
|
final patternName = parts.length > 0 ? parts[0] : ''; |
||||||
|
final patternExt = parts.length > 1 ? parts.sublist(1).join('.') : ''; |
||||||
|
|
||||||
|
// 获取文件的实际名称和扩展名 |
||||||
|
final fileName = path.basename(filePath); |
||||||
|
final fileExt = path.extension(fileName).toLowerCase().replaceFirst('.', ''); |
||||||
|
|
||||||
|
// 匹配文件名部分(如果有指定) |
||||||
|
bool nameMatches = true; |
||||||
|
if (patternName.isNotEmpty && patternName != '*') { |
||||||
|
nameMatches = matchesWildcard(fileName, patternName); |
||||||
|
} |
||||||
|
|
||||||
|
// 匹配扩展名部分(如果有指定) |
||||||
|
bool extMatches = true; |
||||||
|
if (patternExt.isNotEmpty) { |
||||||
|
if (patternExt == '*') { |
||||||
|
extMatches = true; |
||||||
|
} else { |
||||||
|
extMatches = matchesWildcard(fileExt, patternExt); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nameMatches && extMatches; |
||||||
|
} |
||||||
|
|
||||||
|
// 辅助函数:判断字符串是否匹配通配符模式 |
||||||
|
static bool matchesWildcard(String input, String pattern) { |
||||||
|
// 处理特殊情况 |
||||||
|
if (pattern == '*') return true; |
||||||
|
|
||||||
|
// 动态规划实现通配符匹配 |
||||||
|
final m = input.length; |
||||||
|
final n = pattern.length; |
||||||
|
final dp = List.generate(m + 1, (_) => List.filled(n + 1, false)); |
||||||
|
|
||||||
|
// 空字符串匹配空模式 |
||||||
|
dp[0][0] = true; |
||||||
|
|
||||||
|
// 处理模式以 * 开头的情况 |
||||||
|
for (int j = 1; j <= n; j++) { |
||||||
|
if (pattern[j - 1] == '*') { |
||||||
|
dp[0][j] = dp[0][j - 1]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 填充动态规划表 |
||||||
|
for (int i = 1; i <= m; i++) { |
||||||
|
for (int j = 1; j <= n; j++) { |
||||||
|
if (pattern[j - 1] == '*') { |
||||||
|
// * 可以匹配任意字符或空字符 |
||||||
|
dp[i][j] = dp[i][j - 1] || dp[i - 1][j]; |
||||||
|
} else if (pattern[j - 1] == '?' || input[i - 1] == pattern[j - 1]) { |
||||||
|
// ? 匹配单个字符,或者字符直接相等 |
||||||
|
dp[i][j] = dp[i - 1][j - 1]; |
||||||
|
} else { |
||||||
|
// 不匹配 |
||||||
|
dp[i][j] = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return dp[m][n]; |
||||||
|
} |
||||||
|
} |
@ -1,405 +0,0 @@ |
|||||||
// lib/app/modules/content_search/content_search_service.dart |
|
||||||
|
|
||||||
import 'dart:convert'; |
|
||||||
import 'dart:io'; |
|
||||||
import 'package:flutter_js/flutter_js.dart'; |
|
||||||
import 'package:path/path.dart' as path; |
|
||||||
import 'package:win_text_editor/framework/controllers/logger.dart'; |
|
||||||
import 'package:win_text_editor/modules/content_search/models/match_result.dart'; |
|
||||||
import 'package:win_text_editor/modules/content_search/models/search_mode.dart'; |
|
||||||
import 'package:win_text_editor/modules/content_search/models/search_result.dart'; |
|
||||||
|
|
||||||
typedef ProgressCallback = void Function(double progress); |
|
||||||
|
|
||||||
class ContentSearchService { |
|
||||||
/// 执行定位搜索(返回所有匹配项) |
|
||||||
static Future<List<SearchResult>> performLocateSearch({ |
|
||||||
required String directory, |
|
||||||
required String query, |
|
||||||
required String fileType, |
|
||||||
required bool caseSensitive, |
|
||||||
required bool wholeWord, |
|
||||||
required bool useRegex, |
|
||||||
ProgressCallback? onProgress, |
|
||||||
bool Function()? shouldStop, |
|
||||||
}) async { |
|
||||||
final results = <SearchResult>[]; |
|
||||||
final dir = Directory(directory); |
|
||||||
final queries = _splitQuery(query); |
|
||||||
|
|
||||||
// 先统计文件总数 |
|
||||||
int totalFiles = 0; |
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
totalFiles++; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
onProgress?.call(1); |
|
||||||
|
|
||||||
int words = 0; |
|
||||||
int processedFiles = 0; |
|
||||||
int oldProgress = 1; |
|
||||||
for (final q in queries) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
|
|
||||||
final pattern = _buildSearchPattern( |
|
||||||
query: q, |
|
||||||
caseSensitive: caseSensitive, |
|
||||||
wholeWord: wholeWord, |
|
||||||
useRegex: useRegex, |
|
||||||
); |
|
||||||
|
|
||||||
Logger().info("搜索词 $q 开始[${++words}/${queries.length}]"); |
|
||||||
|
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
|
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
processedFiles++; |
|
||||||
final progress = (processedFiles / (totalFiles * queries.length)) * 99 + 1; |
|
||||||
if (progress - oldProgress >= 1) { |
|
||||||
oldProgress = progress.floor(); |
|
||||||
onProgress?.call(progress); |
|
||||||
} |
|
||||||
|
|
||||||
await _searchInFile(entity, pattern, results, q); |
|
||||||
} |
|
||||||
} |
|
||||||
Logger().info("搜索词 $q 结束[$words/${queries.length}],搜索文件 $totalFiles 个,"); |
|
||||||
} |
|
||||||
|
|
||||||
onProgress?.call(100); |
|
||||||
return results; |
|
||||||
} |
|
||||||
|
|
||||||
/// 计数搜索(返回每个关键词的匹配数) |
|
||||||
static Future<Map<String, int>> performCountSearch({ |
|
||||||
required String directory, |
|
||||||
required String query, |
|
||||||
required String fileType, |
|
||||||
required bool caseSensitive, |
|
||||||
required bool wholeWord, |
|
||||||
required bool useRegex, |
|
||||||
ProgressCallback? onProgress, |
|
||||||
bool Function()? shouldStop, |
|
||||||
}) async { |
|
||||||
final counts = <String, int>{}; |
|
||||||
final dir = Directory(directory); |
|
||||||
final queries = _splitQuery(query); // 分割查询字符串 |
|
||||||
|
|
||||||
int totalFiles = 0; |
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return counts; |
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
totalFiles++; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
onProgress?.call(1); |
|
||||||
|
|
||||||
int words = 0; |
|
||||||
int processedFiles = 0; |
|
||||||
int oldProgress = 1; |
|
||||||
for (final q in queries) { |
|
||||||
if (shouldStop?.call() == true) return counts; |
|
||||||
|
|
||||||
final pattern = _buildSearchPattern( |
|
||||||
query: q, |
|
||||||
caseSensitive: caseSensitive, |
|
||||||
wholeWord: wholeWord, |
|
||||||
useRegex: useRegex, |
|
||||||
); |
|
||||||
|
|
||||||
Logger().info("搜索词 $q 开始[${++words}/${queries.length}]"); |
|
||||||
counts[q] = 0; |
|
||||||
|
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return counts; |
|
||||||
|
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
processedFiles++; |
|
||||||
final progress = (processedFiles / (totalFiles * queries.length)) * 99 + 1; |
|
||||||
if (progress - oldProgress >= 1) { |
|
||||||
oldProgress = progress.floor(); |
|
||||||
onProgress?.call(progress); |
|
||||||
} |
|
||||||
|
|
||||||
await _countInFile(entity, pattern, counts, q); // 传递当前查询项 |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Logger().info("搜索词 $q 结束[$words/${queries.length}],搜索文件 $totalFiles 个,"); |
|
||||||
} |
|
||||||
|
|
||||||
return counts; |
|
||||||
} |
|
||||||
|
|
||||||
/// 新增方法:执行自定义 JavaScript 规则搜索 |
|
||||||
static Future<List<SearchResult>> performCustomSearch({ |
|
||||||
required String directory, |
|
||||||
required String fileType, |
|
||||||
required String jsFunction, |
|
||||||
required SearchMode searchMode, |
|
||||||
ProgressCallback? onProgress, |
|
||||||
bool Function()? shouldStop, |
|
||||||
}) async { |
|
||||||
final results = <SearchResult>[]; |
|
||||||
int count = 0; |
|
||||||
final dir = Directory(directory); |
|
||||||
final jsRuntime = getJavascriptRuntime(); |
|
||||||
|
|
||||||
try { |
|
||||||
// 定义 JavaScript 函数 |
|
||||||
final jsCode = 'function match(content){$jsFunction};'; |
|
||||||
|
|
||||||
jsRuntime.evaluate(jsCode); |
|
||||||
|
|
||||||
int totalFiles = 0; |
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
totalFiles++; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
onProgress?.call(1); |
|
||||||
|
|
||||||
int processedFiles = 0; |
|
||||||
int calledProcessedFiles = 1; |
|
||||||
|
|
||||||
await for (final entity in dir.list(recursive: true)) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
if (entity is File && _matchesFileType(entity.path, fileType)) { |
|
||||||
try { |
|
||||||
final lines = await entity.readAsLines(); |
|
||||||
for (int i = 0; i < lines.length; i++) { |
|
||||||
if (shouldStop?.call() == true) return results; |
|
||||||
final line = lines[i].trim(); |
|
||||||
if (line.length < 3) continue; // 跳过短行 |
|
||||||
|
|
||||||
final result = jsRuntime.evaluate('match(${jsonEncode(line)});'); |
|
||||||
if (result.isError) { |
|
||||||
throw Exception('JS Error: ${result.stringResult}'); |
|
||||||
} |
|
||||||
|
|
||||||
if (result.stringResult == 'true') { |
|
||||||
if (searchMode == SearchMode.locate) { |
|
||||||
results.add( |
|
||||||
SearchResult( |
|
||||||
filePath: entity.path, |
|
||||||
lineNumber: i + 1, |
|
||||||
lineContent: line, |
|
||||||
matches: [], |
|
||||||
queryTerm: "Custom Rule", |
|
||||||
), |
|
||||||
); |
|
||||||
} else { |
|
||||||
count++; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Logger().error('Error in file ${entity.path}: $e'); |
|
||||||
} |
|
||||||
|
|
||||||
processedFiles++; |
|
||||||
final progress = (processedFiles / totalFiles) * 99 + 1; |
|
||||||
if (processedFiles - calledProcessedFiles >= 1) { |
|
||||||
calledProcessedFiles = processedFiles; |
|
||||||
onProgress?.call(progress); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 处理计数模式结果 |
|
||||||
if (searchMode == SearchMode.count) { |
|
||||||
results.add( |
|
||||||
SearchResult( |
|
||||||
filePath: "Custom Rule", |
|
||||||
lineNumber: count, |
|
||||||
lineContent: '', |
|
||||||
matches: [], |
|
||||||
queryTerm: "Custom Rule", |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} finally { |
|
||||||
onProgress?.call(100); |
|
||||||
jsRuntime.dispose(); |
|
||||||
} |
|
||||||
|
|
||||||
return results; |
|
||||||
} |
|
||||||
|
|
||||||
/// 分割查询字符串(按半角逗号分隔,并去除空格) |
|
||||||
static List<String> _splitQuery(String query) { |
|
||||||
return query.split(',').map((q) => q.trim()).where((q) => q.isNotEmpty).toList(); |
|
||||||
} |
|
||||||
|
|
||||||
/// 构建正则表达式(原逻辑不变) |
|
||||||
static RegExp _buildSearchPattern({ |
|
||||||
required String query, |
|
||||||
required bool caseSensitive, |
|
||||||
required bool wholeWord, |
|
||||||
required bool useRegex, |
|
||||||
}) { |
|
||||||
String pattern; |
|
||||||
if (useRegex) { |
|
||||||
pattern = query; |
|
||||||
} else { |
|
||||||
pattern = RegExp.escape(query); |
|
||||||
if (wholeWord) { |
|
||||||
pattern = '\\b$pattern\\b'; |
|
||||||
} |
|
||||||
} |
|
||||||
return RegExp(pattern, caseSensitive: caseSensitive, multiLine: true); |
|
||||||
} |
|
||||||
|
|
||||||
static bool _matchesFileType(String filePath, String fileType) { |
|
||||||
// 处理特殊情况 |
|
||||||
if (fileType == '*.*') return true; |
|
||||||
if (fileType == '*') return true; |
|
||||||
|
|
||||||
// 分割通配符为文件名和扩展名部分 |
|
||||||
final parts = fileType.split('.'); |
|
||||||
final patternName = parts.length > 0 ? parts[0] : ''; |
|
||||||
final patternExt = parts.length > 1 ? parts.sublist(1).join('.') : ''; |
|
||||||
|
|
||||||
// 获取文件的实际名称和扩展名 |
|
||||||
final fileName = path.basename(filePath); |
|
||||||
final fileExt = path.extension(fileName).toLowerCase().replaceFirst('.', ''); |
|
||||||
|
|
||||||
// 匹配文件名部分(如果有指定) |
|
||||||
bool nameMatches = true; |
|
||||||
if (patternName.isNotEmpty && patternName != '*') { |
|
||||||
nameMatches = _matchesWildcard(fileName, patternName); |
|
||||||
} |
|
||||||
|
|
||||||
// 匹配扩展名部分(如果有指定) |
|
||||||
bool extMatches = true; |
|
||||||
if (patternExt.isNotEmpty) { |
|
||||||
if (patternExt == '*') { |
|
||||||
extMatches = true; |
|
||||||
} else { |
|
||||||
extMatches = _matchesWildcard(fileExt, patternExt); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return nameMatches && extMatches; |
|
||||||
} |
|
||||||
|
|
||||||
// 辅助函数:判断字符串是否匹配通配符模式 |
|
||||||
static bool _matchesWildcard(String input, String pattern) { |
|
||||||
// 处理特殊情况 |
|
||||||
if (pattern == '*') return true; |
|
||||||
|
|
||||||
// 动态规划实现通配符匹配 |
|
||||||
final m = input.length; |
|
||||||
final n = pattern.length; |
|
||||||
final dp = List.generate(m + 1, (_) => List.filled(n + 1, false)); |
|
||||||
|
|
||||||
// 空字符串匹配空模式 |
|
||||||
dp[0][0] = true; |
|
||||||
|
|
||||||
// 处理模式以 * 开头的情况 |
|
||||||
for (int j = 1; j <= n; j++) { |
|
||||||
if (pattern[j - 1] == '*') { |
|
||||||
dp[0][j] = dp[0][j - 1]; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 填充动态规划表 |
|
||||||
for (int i = 1; i <= m; i++) { |
|
||||||
for (int j = 1; j <= n; j++) { |
|
||||||
if (pattern[j - 1] == '*') { |
|
||||||
// * 可以匹配任意字符或空字符 |
|
||||||
dp[i][j] = dp[i][j - 1] || dp[i - 1][j]; |
|
||||||
} else if (pattern[j - 1] == '?' || input[i - 1] == pattern[j - 1]) { |
|
||||||
// ? 匹配单个字符,或者字符直接相等 |
|
||||||
dp[i][j] = dp[i - 1][j - 1]; |
|
||||||
} else { |
|
||||||
// 不匹配 |
|
||||||
dp[i][j] = false; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return dp[m][n]; |
|
||||||
} |
|
||||||
|
|
||||||
/// 在文件中搜索匹配项(增加 queryTerm 参数) |
|
||||||
static Future<void> _searchInFile( |
|
||||||
File file, |
|
||||||
RegExp pattern, |
|
||||||
List<SearchResult> results, |
|
||||||
String queryTerm, // 当前查询项 |
|
||||||
) async { |
|
||||||
try { |
|
||||||
final lines = await file.readAsLines(); |
|
||||||
for (int i = 0; i < lines.length; i++) { |
|
||||||
final line = lines[i].trim(); |
|
||||||
final matches = pattern.allMatches(line); |
|
||||||
|
|
||||||
if (matches.isNotEmpty) { |
|
||||||
results.add( |
|
||||||
SearchResult( |
|
||||||
filePath: file.path, |
|
||||||
lineNumber: i + 1, |
|
||||||
lineContent: line, |
|
||||||
matches: matches.map((m) => MatchResult(start: m.start, end: m.end)).toList(), |
|
||||||
queryTerm: queryTerm, // 记录匹配的查询项 |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Logger().error('Error reading file ${file.path}: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// 在文件中计数匹配项(增加 queryTerm 参数) |
|
||||||
static Future<void> _countInFile( |
|
||||||
File file, |
|
||||||
RegExp pattern, |
|
||||||
Map<String, int> counts, |
|
||||||
String queryTerm, // 当前查询项 |
|
||||||
) async { |
|
||||||
try { |
|
||||||
final content = await file.readAsString(); |
|
||||||
final matches = pattern.allMatches(content); |
|
||||||
|
|
||||||
if (matches.isNotEmpty) { |
|
||||||
counts[queryTerm] = (counts[queryTerm] ?? 0) + matches.length; |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
Logger().error('Error reading file ${file.path}: $e'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// 在 ContentSearchService 类中添加以下方法 |
|
||||||
|
|
||||||
/// 验证 JavaScript 自定义规则的语法 |
|
||||||
static JsEvalResult validateJsRule(String jsFunction) { |
|
||||||
final jsRuntime = getJavascriptRuntime(); |
|
||||||
try { |
|
||||||
final jsCode = 'function match(content) {$jsFunction};'; |
|
||||||
// 执行 JS 代码进行语法检查 |
|
||||||
final result = jsRuntime.evaluate(jsCode); |
|
||||||
if (result.isError) { |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
// 测试函数是否返回布尔值 |
|
||||||
final test = jsRuntime.evaluate("match('test');"); |
|
||||||
if (test.stringResult != 'true' && test.stringResult != 'false') { |
|
||||||
return JsEvalResult('Custom rule must return boolean (true/false)', null, isError: true); |
|
||||||
} |
|
||||||
|
|
||||||
return test; |
|
||||||
} finally { |
|
||||||
jsRuntime.dispose(); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,218 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
import 'dart:io'; |
||||||
|
import 'dart:isolate'; |
||||||
|
import 'dart:math'; |
||||||
|
import 'package:win_text_editor/modules/content_search/services/base_search_service.dart'; |
||||||
|
|
||||||
|
class CountSearchService { |
||||||
|
static const _maxConcurrentIsolates = 8; // 根据CPU核心数调整 |
||||||
|
static const _queriesPerBatch = 500; // 每批搜索词数量 |
||||||
|
|
||||||
|
static List<List<T>> _chunkList<T>(List<T> list, int chunkSize) { |
||||||
|
return List.generate( |
||||||
|
(list.length / chunkSize).ceil(), |
||||||
|
(i) => list.sublist( |
||||||
|
i * chunkSize, |
||||||
|
i * chunkSize + chunkSize > list.length ? list.length : i * chunkSize + chunkSize, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
static Future<Map<String, int>> performSearch({ |
||||||
|
required String directory, |
||||||
|
required String query, |
||||||
|
required String fileType, |
||||||
|
required bool caseSensitive, |
||||||
|
required bool wholeWord, |
||||||
|
required bool useRegex, |
||||||
|
ProgressCallback? onProgress, |
||||||
|
bool Function()? shouldStop, |
||||||
|
}) async { |
||||||
|
if (shouldStop?.call() == true) return {}; |
||||||
|
|
||||||
|
final counts = <String, int>{}; |
||||||
|
final allQueries = BaseSearchService.splitQuery(query); |
||||||
|
|
||||||
|
// 分割搜索词为批次 |
||||||
|
final queryBatches = _chunkList(allQueries, _queriesPerBatch); |
||||||
|
|
||||||
|
// 获取所有目标文件路径 |
||||||
|
final filePaths = await _collectFilePaths(directory, fileType, shouldStop); |
||||||
|
if (filePaths.isEmpty) return counts; |
||||||
|
|
||||||
|
// 启动Isolate池 |
||||||
|
final resultPort = ReceivePort(); |
||||||
|
final stopPort = ReceivePort(); |
||||||
|
final completer = Completer<Map<String, int>>(); |
||||||
|
int activeIsolates = 0; |
||||||
|
|
||||||
|
// 进度处理 |
||||||
|
int processedFiles = 0; |
||||||
|
void updateProgress() { |
||||||
|
final progress = (processedFiles / filePaths.length) * 100; |
||||||
|
onProgress?.call(progress); |
||||||
|
} |
||||||
|
|
||||||
|
final initIsolates = min(2, _maxConcurrentIsolates); // 首批启动2个 |
||||||
|
for (int i = 0; i < initIsolates; i++) { |
||||||
|
_spawnIsolateWorker( |
||||||
|
filePaths.sublist(i, i + 1), // 每个Isolate处理部分文件 |
||||||
|
queryBatches, |
||||||
|
resultPort.sendPort, |
||||||
|
stopPort.sendPort, |
||||||
|
caseSensitive, |
||||||
|
wholeWord, |
||||||
|
useRegex, |
||||||
|
); |
||||||
|
activeIsolates++; |
||||||
|
} |
||||||
|
|
||||||
|
// 分发任务到Isolate |
||||||
|
Future.delayed(const Duration(milliseconds: 300), () { |
||||||
|
for (int i = activeIsolates; i < min(_maxConcurrentIsolates, filePaths.length); i++) { |
||||||
|
_spawnIsolateWorker( |
||||||
|
filePaths.sublist(i, i + 1), // 每个Isolate处理部分文件 |
||||||
|
queryBatches, |
||||||
|
resultPort.sendPort, |
||||||
|
stopPort.sendPort, |
||||||
|
caseSensitive, |
||||||
|
wholeWord, |
||||||
|
useRegex, |
||||||
|
); |
||||||
|
activeIsolates++; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// 监听结果 |
||||||
|
resultPort.listen((msg) { |
||||||
|
if (msg is Map<String, int>) { |
||||||
|
msg.forEach((query, count) { |
||||||
|
counts.update(query, (v) => v + count, ifAbsent: () => count); |
||||||
|
}); |
||||||
|
processedFiles++; |
||||||
|
updateProgress(); |
||||||
|
|
||||||
|
// 继续分配新任务 |
||||||
|
if (activeIsolates < filePaths.length) { |
||||||
|
_spawnIsolateWorker( |
||||||
|
[filePaths[activeIsolates]], |
||||||
|
queryBatches, |
||||||
|
resultPort.sendPort, |
||||||
|
stopPort.sendPort, |
||||||
|
caseSensitive, |
||||||
|
wholeWord, |
||||||
|
useRegex, |
||||||
|
); |
||||||
|
activeIsolates++; |
||||||
|
} else if (processedFiles == filePaths.length) { |
||||||
|
completer.complete(counts); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// 处理停止请求 |
||||||
|
stopPort.listen((_) => completer.complete(counts)); |
||||||
|
|
||||||
|
return completer.future.whenComplete(() { |
||||||
|
resultPort.close(); |
||||||
|
stopPort.close(); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
static Future<List<String>> _collectFilePaths( |
||||||
|
String directory, |
||||||
|
String fileType, |
||||||
|
bool Function()? shouldStop, |
||||||
|
) async { |
||||||
|
final dir = Directory(directory); |
||||||
|
final paths = <String>[]; |
||||||
|
await for (final entity in dir.list(recursive: true)) { |
||||||
|
if (shouldStop?.call() == true) break; |
||||||
|
if (entity is File && BaseSearchService.matchesFileType(entity.path, fileType)) { |
||||||
|
paths.add(entity.path); |
||||||
|
} |
||||||
|
} |
||||||
|
return paths; |
||||||
|
} |
||||||
|
|
||||||
|
static void _spawnIsolateWorker( |
||||||
|
List<String> filePaths, |
||||||
|
List<List<String>> queryBatches, |
||||||
|
SendPort resultPort, |
||||||
|
SendPort stopPort, |
||||||
|
bool caseSensitive, |
||||||
|
bool wholeWord, |
||||||
|
bool useRegex, |
||||||
|
) async { |
||||||
|
await Isolate.spawn( |
||||||
|
_isolateEntry, |
||||||
|
_IsolateData( |
||||||
|
filePaths, |
||||||
|
queryBatches, |
||||||
|
resultPort, |
||||||
|
stopPort, |
||||||
|
caseSensitive, |
||||||
|
wholeWord, |
||||||
|
useRegex, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
static void _isolateEntry(_IsolateData data) { |
||||||
|
final localCounts = <String, int>{}; |
||||||
|
for (final filePath in data.filePaths) { |
||||||
|
try { |
||||||
|
final content = File(filePath).readAsStringSync(); |
||||||
|
for (final batch in data.queryBatches) { |
||||||
|
// 并行处理每个搜索词批次 |
||||||
|
final batchResults = ParallelTask.runSync(() { |
||||||
|
final batchCounts = <String, int>{}; |
||||||
|
for (final query in batch) { |
||||||
|
final pattern = BaseSearchService.buildSearchPattern( |
||||||
|
query: query, |
||||||
|
caseSensitive: data.caseSensitive, |
||||||
|
wholeWord: data.wholeWord, |
||||||
|
useRegex: data.useRegex, |
||||||
|
); |
||||||
|
batchCounts[query] = pattern.allMatches(content).length; |
||||||
|
} |
||||||
|
return batchCounts; |
||||||
|
}); |
||||||
|
|
||||||
|
batchResults.forEach((query, count) { |
||||||
|
localCounts.update(query, (v) => v + count, ifAbsent: () => count); |
||||||
|
}); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
print('Isolate error: $e'); |
||||||
|
} |
||||||
|
} |
||||||
|
data.resultPort.send(localCounts); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Isolate通信数据结构 |
||||||
|
class _IsolateData { |
||||||
|
final List<String> filePaths; |
||||||
|
final List<List<String>> queryBatches; |
||||||
|
final SendPort resultPort; |
||||||
|
final SendPort stopPort; |
||||||
|
final bool caseSensitive; |
||||||
|
final bool wholeWord; |
||||||
|
final bool useRegex; |
||||||
|
|
||||||
|
_IsolateData( |
||||||
|
this.filePaths, |
||||||
|
this.queryBatches, |
||||||
|
this.resultPort, |
||||||
|
this.stopPort, |
||||||
|
this.caseSensitive, |
||||||
|
this.wholeWord, |
||||||
|
this.useRegex, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// 模拟并行任务处理 |
||||||
|
class ParallelTask { |
||||||
|
static T runSync<T>(T Function() task) => task(); |
||||||
|
} |
@ -0,0 +1,133 @@ |
|||||||
|
// lib/app/modules/content_search/content_search_service.dart |
||||||
|
|
||||||
|
import 'dart:convert'; |
||||||
|
import 'dart:io'; |
||||||
|
import 'package:flutter_js/flutter_js.dart'; |
||||||
|
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||||
|
import 'package:win_text_editor/modules/content_search/models/search_mode.dart'; |
||||||
|
import 'package:win_text_editor/modules/content_search/models/search_result.dart'; |
||||||
|
import 'package:win_text_editor/modules/content_search/services/base_search_service.dart'; |
||||||
|
|
||||||
|
typedef ProgressCallback = void Function(double progress); |
||||||
|
|
||||||
|
class CustomSearchService { |
||||||
|
/// 新增方法:执行自定义 JavaScript 规则搜索 |
||||||
|
static Future<List<SearchResult>> performCustomSearch({ |
||||||
|
required String directory, |
||||||
|
required String fileType, |
||||||
|
required String jsFunction, |
||||||
|
required SearchMode searchMode, |
||||||
|
ProgressCallback? onProgress, |
||||||
|
bool Function()? shouldStop, |
||||||
|
}) async { |
||||||
|
final results = <SearchResult>[]; |
||||||
|
int count = 0; |
||||||
|
final dir = Directory(directory); |
||||||
|
final jsRuntime = getJavascriptRuntime(); |
||||||
|
|
||||||
|
try { |
||||||
|
// 定义 JavaScript 函数 |
||||||
|
final jsCode = 'function match(content){$jsFunction};'; |
||||||
|
|
||||||
|
jsRuntime.evaluate(jsCode); |
||||||
|
|
||||||
|
int totalFiles = 0; |
||||||
|
await for (final entity in dir.list(recursive: true)) { |
||||||
|
if (shouldStop?.call() == true) return results; |
||||||
|
if (entity is File && BaseSearchService.matchesFileType(entity.path, fileType)) { |
||||||
|
totalFiles++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onProgress?.call(1); |
||||||
|
|
||||||
|
int processedFiles = 0; |
||||||
|
int calledProcessedFiles = 1; |
||||||
|
|
||||||
|
await for (final entity in dir.list(recursive: true)) { |
||||||
|
if (shouldStop?.call() == true) return results; |
||||||
|
if (entity is File && BaseSearchService.matchesFileType(entity.path, fileType)) { |
||||||
|
try { |
||||||
|
final lines = await entity.readAsLines(); |
||||||
|
for (int i = 0; i < lines.length; i++) { |
||||||
|
if (shouldStop?.call() == true) return results; |
||||||
|
final line = lines[i].trim(); |
||||||
|
if (line.length < 3) continue; // 跳过短行 |
||||||
|
|
||||||
|
final result = jsRuntime.evaluate('match(${jsonEncode(line)});'); |
||||||
|
if (result.isError) { |
||||||
|
throw Exception('JS Error: ${result.stringResult}'); |
||||||
|
} |
||||||
|
|
||||||
|
if (result.stringResult == 'true') { |
||||||
|
if (searchMode == SearchMode.locate) { |
||||||
|
results.add( |
||||||
|
SearchResult( |
||||||
|
filePath: entity.path, |
||||||
|
lineNumber: i + 1, |
||||||
|
lineContent: line, |
||||||
|
matches: [], |
||||||
|
queryTerm: "Custom Rule", |
||||||
|
), |
||||||
|
); |
||||||
|
} else { |
||||||
|
count++; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
Logger().error('Error in file ${entity.path}: $e'); |
||||||
|
} |
||||||
|
|
||||||
|
processedFiles++; |
||||||
|
final progress = (processedFiles / totalFiles) * 99 + 1; |
||||||
|
if (processedFiles - calledProcessedFiles >= 1) { |
||||||
|
calledProcessedFiles = processedFiles; |
||||||
|
onProgress?.call(progress); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 处理计数模式结果 |
||||||
|
if (searchMode == SearchMode.count) { |
||||||
|
results.add( |
||||||
|
SearchResult( |
||||||
|
filePath: "Custom Rule", |
||||||
|
lineNumber: count, |
||||||
|
lineContent: '', |
||||||
|
matches: [], |
||||||
|
queryTerm: "Custom Rule", |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
onProgress?.call(100); |
||||||
|
jsRuntime.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
/// 验证 JavaScript 自定义规则的语法 |
||||||
|
static JsEvalResult validateJsRule(String jsFunction) { |
||||||
|
final jsRuntime = getJavascriptRuntime(); |
||||||
|
try { |
||||||
|
final jsCode = 'function match(content) {$jsFunction};'; |
||||||
|
// 执行 JS 代码进行语法检查 |
||||||
|
final result = jsRuntime.evaluate(jsCode); |
||||||
|
if (result.isError) { |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
// 测试函数是否返回布尔值 |
||||||
|
final test = jsRuntime.evaluate("match('test');"); |
||||||
|
if (test.stringResult != 'true' && test.stringResult != 'false') { |
||||||
|
return JsEvalResult('Custom rule must return boolean (true/false)', null, isError: true); |
||||||
|
} |
||||||
|
|
||||||
|
return test; |
||||||
|
} finally { |
||||||
|
jsRuntime.dispose(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,112 @@ |
|||||||
|
// lib/app/modules/content_search/services/locate_search_service.dart |
||||||
|
import 'dart:io'; |
||||||
|
import 'package:win_text_editor/modules/content_search/models/match_result.dart'; |
||||||
|
|
||||||
|
import '../models/search_result.dart'; |
||||||
|
import 'base_search_service.dart'; |
||||||
|
|
||||||
|
class LocateSearchService { |
||||||
|
static Future<List<SearchResult>> performSearch({ |
||||||
|
required String directory, |
||||||
|
required String query, |
||||||
|
required String fileType, |
||||||
|
required bool caseSensitive, |
||||||
|
required bool wholeWord, |
||||||
|
required bool useRegex, |
||||||
|
ProgressCallback? onProgress, |
||||||
|
bool Function()? shouldStop, |
||||||
|
}) async { |
||||||
|
final results = <SearchResult>[]; |
||||||
|
final queries = BaseSearchService.splitQuery(query); |
||||||
|
|
||||||
|
await BaseSearchService.processFiles( |
||||||
|
directory: directory, |
||||||
|
fileType: fileType, |
||||||
|
onProgress: onProgress, |
||||||
|
shouldStop: shouldStop, |
||||||
|
onFile: (file, content) async { |
||||||
|
for (final q in queries) { |
||||||
|
final pattern = BaseSearchService.buildSearchPattern( |
||||||
|
query: q, |
||||||
|
caseSensitive: caseSensitive, |
||||||
|
wholeWord: wholeWord, |
||||||
|
useRegex: useRegex, |
||||||
|
); |
||||||
|
|
||||||
|
final matches = pattern.allMatches(content); |
||||||
|
if (matches.isNotEmpty) { |
||||||
|
results.addAll(_convertMatchesToResults(file, content, matches, q)); |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
/// 将匹配结果转换为 SearchResult 列表(按行拆分) |
||||||
|
static List<SearchResult> _convertMatchesToResults( |
||||||
|
File file, |
||||||
|
String content, |
||||||
|
Iterable<RegExpMatch> matches, |
||||||
|
String queryTerm, |
||||||
|
) { |
||||||
|
final results = <SearchResult>[]; |
||||||
|
final lines = content.split('\n'); |
||||||
|
|
||||||
|
// 创建一个映射表:行号 -> 该行的所有匹配项 |
||||||
|
final lineMatches = <int, List<RegExpMatch>>{}; |
||||||
|
for (final match in matches) { |
||||||
|
// 计算匹配所在的行号 |
||||||
|
final lineNumber = _getLineNumber(lines, match.start); |
||||||
|
lineMatches.putIfAbsent(lineNumber, () => []).add(match); |
||||||
|
} |
||||||
|
|
||||||
|
// 为每行匹配生成结果 |
||||||
|
lineMatches.forEach((lineNumber, matchesInLine) { |
||||||
|
final lineContent = lines[lineNumber - 1]; // 行号从1开始 |
||||||
|
final lineResults = |
||||||
|
matchesInLine.map((m) { |
||||||
|
// 计算匹配在行内的相对位置 |
||||||
|
final lineStartPos = _getLineStartPos(lines, lineNumber - 1); |
||||||
|
final startInLine = m.start - lineStartPos; |
||||||
|
final endInLine = m.end - lineStartPos; |
||||||
|
|
||||||
|
return MatchResult(start: startInLine, end: endInLine); |
||||||
|
}).toList(); |
||||||
|
|
||||||
|
results.add( |
||||||
|
SearchResult( |
||||||
|
filePath: file.path, |
||||||
|
lineNumber: lineNumber, |
||||||
|
lineContent: lineContent, |
||||||
|
matches: lineResults, |
||||||
|
queryTerm: queryTerm, |
||||||
|
), |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
/// 辅助方法:根据字符位置计算行号 |
||||||
|
static int _getLineNumber(List<String> lines, int charPos) { |
||||||
|
int currentPos = 0; |
||||||
|
for (int i = 0; i < lines.length; i++) { |
||||||
|
currentPos += lines[i].length + 1; // +1 换行符 |
||||||
|
if (charPos < currentPos) { |
||||||
|
return i + 1; // 行号从1开始 |
||||||
|
} |
||||||
|
} |
||||||
|
return lines.length; // 默认返回最后一行 |
||||||
|
} |
||||||
|
|
||||||
|
/// 辅助方法:获取指定行的起始字符位置 |
||||||
|
static int _getLineStartPos(List<String> lines, int lineIndex) { |
||||||
|
int pos = 0; |
||||||
|
for (int i = 0; i < lineIndex; i++) { |
||||||
|
pos += lines[i].length + 1; // +1 换行符 |
||||||
|
} |
||||||
|
return pos; |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue