20 changed files with 1425 additions and 202 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools. |
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states |
||||
extensions: |
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/app/menus/app_menu.dart'; |
||||
import 'package:win_text_editor/app/providers/editor_provider.dart'; |
||||
import 'package:win_text_editor/app/providers/file_provider.dart'; |
||||
import 'package:win_text_editor/app/widgets/editor_pane.dart'; |
||||
import 'package:win_text_editor/app/widgets/file_explorer.dart'; |
||||
|
||||
class AppScaffold extends StatelessWidget { |
||||
const AppScaffold({super.key}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return MultiProvider( |
||||
providers: [ |
||||
ChangeNotifierProvider(create: (_) => FileProvider()), |
||||
ChangeNotifierProvider(create: (_) => EditorProvider()), |
||||
], |
||||
child: const Scaffold( |
||||
body: Column( |
||||
children: [ |
||||
// 菜单栏 |
||||
AppMenu(), |
||||
// 主内容区域 |
||||
Expanded( |
||||
child: _ResizablePanel(), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
class _ResizablePanel extends StatefulWidget { |
||||
const _ResizablePanel(); |
||||
|
||||
@override |
||||
State<_ResizablePanel> createState() => _ResizablePanelState(); |
||||
} |
||||
|
||||
class _ResizablePanelState extends State<_ResizablePanel> { |
||||
double _leftWidth = 0.2; // 初始宽度20% |
||||
final double _minWidth = 100; // 最小宽度 |
||||
final double _maxWidth = 400; // 最大宽度 |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final screenWidth = MediaQuery.of(context).size.width; |
||||
|
||||
return LayoutBuilder( |
||||
builder: (context, constraints) { |
||||
// 计算实际宽度 |
||||
final leftPanelWidth = (_leftWidth * screenWidth).clamp(_minWidth, _maxWidth); |
||||
|
||||
return Row( |
||||
children: [ |
||||
// 左侧文件资源管理器 |
||||
SizedBox( |
||||
width: leftPanelWidth, |
||||
child: const FileExplorer(), |
||||
), |
||||
// 拖拽手柄 |
||||
GestureDetector( |
||||
behavior: HitTestBehavior.translucent, |
||||
onPanUpdate: (details) { |
||||
setState(() { |
||||
_leftWidth = ((leftPanelWidth + details.delta.dx) / screenWidth) |
||||
.clamp(_minWidth / screenWidth, _maxWidth / screenWidth); |
||||
}); |
||||
}, |
||||
child: MouseRegion( |
||||
cursor: SystemMouseCursors.resizeLeftRight, |
||||
child: Container( |
||||
width: 4, |
||||
color: Colors.grey[300], |
||||
), |
||||
), |
||||
), |
||||
// 右侧编辑器区域 |
||||
Expanded( |
||||
child: const EditorPane(), |
||||
), |
||||
], |
||||
); |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/menus/menu_constants.dart'; |
||||
|
||||
import 'menu_actions.dart'; |
||||
|
||||
class AppMenu extends StatelessWidget { |
||||
const AppMenu({super.key}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
height: 30, |
||||
color: Colors.grey[200], |
||||
child: Row( |
||||
children: [ |
||||
_buildMenuButton(context, '文件', _buildFileMenuItems()), |
||||
_buildMenuButton(context, '编辑', _buildEditMenuItems()), |
||||
_buildMenuButton(context, '窗口', _buildWindowMenuItems()), |
||||
_buildMenuButton(context, '帮助', _buildHelpMenuItems()), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildMenuButton(BuildContext context, String label, List<PopupMenuEntry<String>> items) { |
||||
return PopupMenuButton<String>( |
||||
offset: const Offset(0, 30), |
||||
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Text(label)), |
||||
itemBuilder: (context) => items, |
||||
onSelected: (value) => MenuActions.handleMenuAction(value, context), |
||||
); |
||||
} |
||||
|
||||
List<PopupMenuEntry<String>> _buildFileMenuItems() { |
||||
return [ |
||||
const PopupMenuItem<String>(value: MenuConstants.openFolder, child: Text('打开文件夹...')), |
||||
const PopupMenuItem<String>(value: MenuConstants.save, child: Text('保存')), |
||||
const PopupMenuItem<String>(value: MenuConstants.saveAs, child: Text('另存为...')), |
||||
const PopupMenuItem<String>(value: MenuConstants.exit, child: Text('退出')), |
||||
]; |
||||
} |
||||
|
||||
List<PopupMenuEntry<String>> _buildEditMenuItems() { |
||||
return [ |
||||
const PopupMenuItem<String>(value: MenuConstants.undo, child: Text('撤销')), |
||||
const PopupMenuItem<String>(value: MenuConstants.redo, child: Text('重做')), |
||||
const PopupMenuItem<String>(value: MenuConstants.cut, child: Text('剪切')), |
||||
const PopupMenuItem<String>(value: MenuConstants.copy, child: Text('复制')), |
||||
const PopupMenuItem<String>(value: MenuConstants.paste, child: Text('粘贴')), |
||||
]; |
||||
} |
||||
|
||||
List<PopupMenuEntry<String>> _buildWindowMenuItems() { |
||||
return [ |
||||
const PopupMenuItem<String>(value: MenuConstants.minimize, child: Text('最小化')), |
||||
const PopupMenuItem<String>(value: MenuConstants.maximize, child: Text('最大化')), |
||||
const PopupMenuItem<String>(value: MenuConstants.close, child: Text('关闭')), |
||||
]; |
||||
} |
||||
|
||||
List<PopupMenuEntry<String>> _buildHelpMenuItems() { |
||||
return [ |
||||
const PopupMenuItem<String>(value: MenuConstants.about, child: Text('关于')), |
||||
const PopupMenuItem<String>(value: MenuConstants.help, child: Text('帮助')), |
||||
]; |
||||
} |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/app/menus/menu_constants.dart'; |
||||
import 'package:win_text_editor/app/providers/file_provider.dart'; |
||||
import 'dart:io'; |
||||
|
||||
class MenuActions { |
||||
static Future<void> handleMenuAction(String value, BuildContext context) async { |
||||
switch (value) { |
||||
case MenuConstants.openFolder: |
||||
await _openFolder(context); |
||||
break; |
||||
case MenuConstants.exit: |
||||
_exitApplication(); |
||||
break; |
||||
// 其他菜单项可以在这里添加处理逻辑 |
||||
} |
||||
} |
||||
|
||||
static Future<void> _openFolder(BuildContext context) async { |
||||
final fileProvider = Provider.of<FileProvider>(context, listen: false); |
||||
final String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); |
||||
|
||||
if (selectedDirectory != null) { |
||||
await fileProvider.loadDirectory(selectedDirectory); |
||||
} |
||||
} |
||||
|
||||
static void _exitApplication() { |
||||
exit(0); |
||||
} |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
class MenuConstants { |
||||
// 菜单项类型 |
||||
static const String fileMenu = 'file'; |
||||
static const String editMenu = 'edit'; |
||||
static const String windowMenu = 'window'; |
||||
static const String helpMenu = 'help'; |
||||
|
||||
// 文件菜单项 |
||||
static const String openFolder = 'open_folder'; |
||||
static const String save = 'save'; |
||||
static const String saveAs = 'save_as'; |
||||
static const String exit = 'exit'; |
||||
|
||||
// 编辑菜单项 |
||||
static const String undo = 'undo'; |
||||
static const String redo = 'redo'; |
||||
static const String cut = 'cut'; |
||||
static const String copy = 'copy'; |
||||
static const String paste = 'paste'; |
||||
|
||||
// 窗口菜单项 |
||||
static const String minimize = 'minimize'; |
||||
static const String maximize = 'maximize'; |
||||
static const String close = 'close'; |
||||
|
||||
// 帮助菜单项 |
||||
static const String about = 'about'; |
||||
static const String help = 'help'; |
||||
} |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
class EditorTab { |
||||
final String id; |
||||
final String title; |
||||
final String path; |
||||
String content; |
||||
final String fileType; |
||||
|
||||
EditorTab({ |
||||
required this.id, |
||||
required this.title, |
||||
required this.path, |
||||
required this.content, |
||||
required this.fileType, |
||||
}); |
||||
} |
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class FileNode { |
||||
final String name; |
||||
final String path; |
||||
final bool isDirectory; |
||||
final bool isRoot; |
||||
final int depth; |
||||
List<FileNode> children; |
||||
bool isExpanded; |
||||
|
||||
FileNode({ |
||||
required this.name, |
||||
required this.path, |
||||
required this.isDirectory, |
||||
this.isRoot = false, |
||||
this.depth = 0, |
||||
this.isExpanded = false, |
||||
List<FileNode>? children, |
||||
}) : children = children ?? []; |
||||
|
||||
// 获取文件图标数据 |
||||
IconData get iconData { |
||||
if (isDirectory) { |
||||
return Icons.folder; |
||||
} |
||||
|
||||
final ext = name.split('.').last.toLowerCase(); |
||||
|
||||
// 常见文件类型图标映射 |
||||
switch (ext) { |
||||
case 'pdf': |
||||
return Icons.picture_as_pdf; |
||||
case 'doc': |
||||
case 'docx': |
||||
return Icons.article; |
||||
case 'xls': |
||||
case 'xlsx': |
||||
return Icons.table_chart; |
||||
case 'ppt': |
||||
case 'pptx': |
||||
return Icons.slideshow; |
||||
case 'txt': |
||||
return Icons.text_snippet; |
||||
case 'dart': |
||||
return Icons.code; |
||||
case 'js': |
||||
return Icons.javascript; |
||||
case 'java': |
||||
return Icons.coffee; |
||||
case 'py': |
||||
return Icons.data_object; |
||||
case 'html': |
||||
return Icons.html; |
||||
case 'css': |
||||
return Icons.css; |
||||
case 'json': |
||||
return Icons.data_array; |
||||
case 'png': |
||||
case 'jpg': |
||||
case 'jpeg': |
||||
case 'gif': |
||||
return Icons.image; |
||||
case 'mp3': |
||||
case 'wav': |
||||
return Icons.audiotrack; |
||||
case 'mp4': |
||||
case 'avi': |
||||
case 'mov': |
||||
return Icons.videocam; |
||||
case 'zip': |
||||
case 'rar': |
||||
case '7z': |
||||
return Icons.archive; |
||||
default: |
||||
return Icons.insert_drive_file; |
||||
} |
||||
} |
||||
|
||||
// 获取构建好的图标组件 |
||||
Widget get icon { |
||||
return Icon(iconData, color: isDirectory ? Colors.amber[700] : Colors.blue); |
||||
} |
||||
|
||||
FileNode copyWith({ |
||||
String? name, |
||||
String? path, |
||||
bool? isDirectory, |
||||
bool? isRoot, |
||||
int? depth, // 添加depth参数 |
||||
List<FileNode>? children, |
||||
bool? isExpanded, |
||||
}) { |
||||
return FileNode( |
||||
name: name ?? this.name, |
||||
path: path ?? this.path, |
||||
isDirectory: isDirectory ?? this.isDirectory, |
||||
isRoot: isRoot ?? this.isRoot, |
||||
depth: depth ?? this.depth, // 保留原有depth或使用新值 |
||||
children: children ?? this.children, |
||||
isExpanded: isExpanded ?? this.isExpanded, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) { |
||||
if (identical(this, other)) return true; |
||||
return other is FileNode && |
||||
other.name == name && |
||||
other.path == path && |
||||
other.isDirectory == isDirectory && |
||||
other.isRoot == isRoot && |
||||
listEquals(other.children, children) && |
||||
other.isExpanded == isExpanded; |
||||
} |
||||
|
||||
@override |
||||
int get hashCode { |
||||
return name.hashCode ^ |
||||
path.hashCode ^ |
||||
isDirectory.hashCode ^ |
||||
isRoot.hashCode ^ |
||||
children.hashCode ^ |
||||
isExpanded.hashCode; |
||||
} |
||||
} |
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:win_text_editor/app/models/editor_tab.dart'; |
||||
import 'package:win_text_editor/app/services/file_service.dart'; |
||||
import 'package:win_text_editor/app/services/syntax_service.dart'; |
||||
|
||||
class EditorProvider with ChangeNotifier { |
||||
final List<EditorTab> _openTabs = []; |
||||
int _activeTabIndex = 0; |
||||
int _currentLayout = 0; // 0=平铺, 1=层叠, 2=单页 |
||||
|
||||
List<EditorTab> get openTabs => _openTabs; |
||||
int get activeTabIndex => _activeTabIndex; |
||||
int get currentLayout => _currentLayout; |
||||
|
||||
Future<void> openFile(String filePath) async { |
||||
try { |
||||
// 检查是否已经打开 |
||||
final existingIndex = _openTabs.indexWhere((tab) => tab.path == filePath); |
||||
if (existingIndex != -1) { |
||||
_activeTabIndex = existingIndex; |
||||
notifyListeners(); |
||||
return; |
||||
} |
||||
|
||||
// 读取文件内容 |
||||
final content = await FileService.readFile(filePath); |
||||
final fileName = filePath.split('/').last; |
||||
final fileType = SyntaxService.detectFileType(fileName); |
||||
|
||||
// 创建新标签页 |
||||
final newTab = EditorTab( |
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), |
||||
title: fileName, |
||||
path: filePath, |
||||
content: content, |
||||
fileType: fileType, |
||||
); |
||||
|
||||
_openTabs.add(newTab); |
||||
_activeTabIndex = _openTabs.length - 1; |
||||
notifyListeners(); |
||||
} catch (e) { |
||||
debugPrint('Error opening file: $e'); |
||||
} |
||||
} |
||||
|
||||
void closeTab(String tabId) { |
||||
final index = _openTabs.indexWhere((tab) => tab.id == tabId); |
||||
if (index != -1) { |
||||
_openTabs.removeAt(index); |
||||
if (_activeTabIndex >= index && _activeTabIndex > 0) { |
||||
_activeTabIndex--; |
||||
} |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
|
||||
void setActiveTab(int index) { |
||||
if (index >= 0 && index < _openTabs.length) { |
||||
_activeTabIndex = index; |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
|
||||
Future<void> saveFile(String tabId) async { |
||||
final index = _openTabs.indexWhere((tab) => tab.id == tabId); |
||||
if (index != -1) { |
||||
final tab = _openTabs[index]; |
||||
await FileService.writeFile(tab.path, tab.content); |
||||
// 可以添加保存成功的提示 |
||||
} |
||||
} |
||||
|
||||
Future<void> copyToClipboard(String tabId) async { |
||||
final index = _openTabs.indexWhere((tab) => tab.id == tabId); |
||||
if (index != -1) { |
||||
final tab = _openTabs[index]; |
||||
// 这里需要实现复制到剪贴板的逻辑 |
||||
// 可以使用 clipboard package |
||||
} |
||||
} |
||||
|
||||
void changeLayout(int layout) { |
||||
if (layout >= 0 && layout <= 2) { |
||||
_currentLayout = layout; |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
|
||||
void updateTabContent(String tabId, String newContent) { |
||||
final index = _openTabs.indexWhere((tab) => tab.id == tabId); |
||||
if (index != -1) { |
||||
_openTabs[index].content = newContent; |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
|
||||
import 'dart:io'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
import 'package:win_text_editor/app/models/file_node.dart'; |
||||
import 'package:win_text_editor/app/services/file_service.dart'; |
||||
|
||||
class FileProvider with ChangeNotifier { |
||||
List<FileNode> _fileNodes = []; |
||||
bool _isLoading = false; |
||||
String _searchQuery = ''; |
||||
String? _currentRootPath; // 跟踪当前根路径 |
||||
|
||||
bool get isLoading => _isLoading; |
||||
bool get hasRoot => _fileNodes.isNotEmpty && _fileNodes[0].isRoot; |
||||
|
||||
// 移除构造函数的_initFileTree调用 |
||||
FileProvider(); |
||||
|
||||
// 新增方法:手动设置根路径 |
||||
Future<void> setRootPath(String path) async { |
||||
_currentRootPath = path; |
||||
await _loadRootDirectory(); |
||||
} |
||||
|
||||
List<FileNode> get fileNodes => |
||||
_searchQuery.isEmpty ? _fileNodes : _fileNodes.where((node) => _filterNode(node)).toList(); |
||||
|
||||
bool _filterNode(FileNode node) { |
||||
if (node.name.toLowerCase().contains(_searchQuery.toLowerCase())) { |
||||
return true; |
||||
} |
||||
return node.children.any(_filterNode); |
||||
} |
||||
|
||||
void searchFiles(String query) { |
||||
_searchQuery = query; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
void toggleExpand(FileNode node) { |
||||
node.isExpanded = !node.isExpanded; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
Future<void> pickAndOpenFile() async { |
||||
final result = await FilePicker.platform.pickFiles(); |
||||
if (result != null && result.files.single.path != null) { |
||||
// 这里需要与编辑器提供者交互来打开文件 |
||||
debugPrint('File selected: ${result.files.single.path}'); |
||||
} |
||||
} |
||||
|
||||
Future<void> loadDirectory(String path) async { |
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
final directory = Directory(path); |
||||
final rootNode = FileNode( |
||||
name: directory.path.split(Platform.pathSeparator).last, |
||||
path: directory.path, |
||||
isDirectory: true, |
||||
isRoot: true, // 添加根节点标识 |
||||
children: await FileService.buildFileTree(directory.path), |
||||
); |
||||
_fileNodes = [rootNode]; // 将根节点作为唯一顶层节点 |
||||
} catch (e) { |
||||
debugPrint('Error loading directory: $e'); |
||||
_fileNodes = []; |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
Future<void> _loadRootDirectory() async { |
||||
if (_currentRootPath == null) return; |
||||
|
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
_fileNodes = [ |
||||
FileNode( |
||||
name: _currentRootPath!.split(Platform.pathSeparator).last, |
||||
path: _currentRootPath!, |
||||
isDirectory: true, |
||||
isRoot: true, |
||||
depth: 0, // 根节点深度为0 |
||||
children: [], // 初始为空,不加载内容 |
||||
), |
||||
]; |
||||
} catch (e) { |
||||
debugPrint('Error loading root directory: $e'); |
||||
_fileNodes = []; |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
Future<void> loadRootDirectory(String path) async { |
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
_fileNodes = [ |
||||
FileNode( |
||||
name: path.split(Platform.pathSeparator).last, |
||||
path: path, |
||||
isDirectory: true, |
||||
isRoot: true, |
||||
children: [], // 初始为空 |
||||
), |
||||
]; |
||||
} catch (e) { |
||||
debugPrint('Error loading root: $e'); |
||||
_fileNodes = []; |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
Future<void> toggleDirectory(FileNode dirNode) async { |
||||
if (dirNode.children.isEmpty) { |
||||
// 首次点击:加载内容 |
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
dirNode.children = await FileService.listDirectory(dirNode.path); |
||||
dirNode.isExpanded = true; |
||||
} catch (e) { |
||||
debugPrint('Error loading dir: $e'); |
||||
dirNode.children = []; |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} else { |
||||
// 已加载过:只切换展开状态 |
||||
dirNode.isExpanded = !dirNode.isExpanded; |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
|
||||
Future<void> loadDirectoryContents(FileNode dirNode) async { |
||||
if (dirNode.children.isNotEmpty && dirNode.isExpanded) { |
||||
// 如果已经加载过且是展开状态,只切换展开状态 |
||||
dirNode.isExpanded = !dirNode.isExpanded; |
||||
notifyListeners(); |
||||
return; |
||||
} |
||||
|
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
final contents = await FileService.listDirectory( |
||||
dirNode.path, |
||||
parentDepth: dirNode.depth, // 确保传递父节点深度 |
||||
); |
||||
|
||||
final updatedNode = dirNode.copyWith( |
||||
children: contents, |
||||
isExpanded: true, |
||||
// 不需要设置 depth,因为 copyWith 会自动保留原值 |
||||
); |
||||
|
||||
_replaceNodeInTree(dirNode, updatedNode); |
||||
} catch (e) { |
||||
debugPrint('Error loading directory contents: $e'); |
||||
final updatedNode = dirNode.copyWith(children: []); |
||||
_replaceNodeInTree(dirNode, updatedNode); |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
void _replaceNodeInTree(FileNode oldNode, FileNode newNode) { |
||||
for (int i = 0; i < _fileNodes.length; i++) { |
||||
if (_fileNodes[i] == oldNode) { |
||||
_fileNodes[i] = newNode; |
||||
return; |
||||
} |
||||
_replaceNodeInChildren(_fileNodes[i], oldNode, newNode); |
||||
} |
||||
} |
||||
|
||||
void _replaceNodeInChildren(FileNode parent, FileNode oldNode, FileNode newNode) { |
||||
for (int i = 0; i < parent.children.length; i++) { |
||||
if (parent.children[i] == oldNode) { |
||||
parent.children[i] = newNode; |
||||
return; |
||||
} |
||||
_replaceNodeInChildren(parent.children[i], oldNode, newNode); |
||||
} |
||||
} |
||||
|
||||
Future<void> refreshFileTree({bool loadContent = false}) async { |
||||
_isLoading = true; |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
final rootDir = await getApplicationDocumentsDirectory(); |
||||
_fileNodes = [ |
||||
FileNode( |
||||
name: rootDir.path.split(Platform.pathSeparator).last, |
||||
path: rootDir.path, |
||||
isDirectory: true, |
||||
isRoot: true, |
||||
// 初始不加载内容 |
||||
children: loadContent ? await FileService.listDirectory(rootDir.path) : [], |
||||
), |
||||
]; |
||||
} catch (e) { |
||||
debugPrint('Error refreshing file tree: $e'); |
||||
_fileNodes = []; |
||||
} |
||||
|
||||
_isLoading = false; |
||||
notifyListeners(); |
||||
} |
||||
} |
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
import 'dart:io'; |
||||
import 'package:win_text_editor/app/models/file_node.dart'; |
||||
|
||||
class FileService { |
||||
/// 延时加载目录内容(不递归) |
||||
static Future<List<FileNode>> listDirectory(String path, {int parentDepth = 0}) async { |
||||
final directory = Directory(path); |
||||
final List<FileNode> nodes = []; |
||||
|
||||
if (await directory.exists()) { |
||||
final entities = directory.listSync(); |
||||
for (final entity in entities) { |
||||
nodes.add( |
||||
FileNode( |
||||
name: entity.path.split(Platform.pathSeparator).last, |
||||
path: entity.path, |
||||
isDirectory: entity is Directory, |
||||
depth: parentDepth + 1, // 关键修复:基于父节点深度+1 |
||||
children: [], |
||||
), |
||||
); |
||||
} |
||||
} |
||||
return nodes; |
||||
} |
||||
|
||||
/// 递归构建完整文件树(原方法保留备用) |
||||
static Future<List<FileNode>> buildFileTree(String rootPath) async { |
||||
final rootDirectory = Directory(rootPath); |
||||
final List<FileNode> nodes = []; |
||||
|
||||
if (await rootDirectory.exists()) { |
||||
final entities = rootDirectory.listSync(); |
||||
|
||||
for (final entity in entities) { |
||||
final node = FileNode( |
||||
name: entity.path.split(Platform.pathSeparator).last, |
||||
path: entity.path, |
||||
isDirectory: entity is Directory, |
||||
); |
||||
|
||||
if (entity is Directory) { |
||||
node.children.addAll(await buildFileTree(entity.path)); |
||||
} |
||||
|
||||
nodes.add(node); |
||||
} |
||||
} |
||||
|
||||
return nodes; |
||||
} |
||||
|
||||
static Future<String> readFile(String filePath) async { |
||||
return await File(filePath).readAsString(); |
||||
} |
||||
|
||||
static Future<void> writeFile(String filePath, String content) async { |
||||
await File(filePath).writeAsString(content); |
||||
} |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
class SyntaxService { |
||||
static String detectFileType(String fileName) { |
||||
final extension = fileName.split('.').last.toLowerCase(); |
||||
|
||||
switch (extension) { |
||||
case 'dart': |
||||
return 'dart'; |
||||
case 'java': |
||||
return 'java'; |
||||
case 'js': |
||||
return 'javascript'; |
||||
case 'py': |
||||
return 'python'; |
||||
case 'html': |
||||
return 'html'; |
||||
case 'css': |
||||
return 'css'; |
||||
case 'json': |
||||
return 'json'; |
||||
case 'xml': |
||||
return 'xml'; |
||||
case 'md': |
||||
return 'markdown'; |
||||
case 'yaml': |
||||
case 'yml': |
||||
return 'yaml'; |
||||
default: |
||||
return 'text'; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/app/providers/editor_provider.dart'; |
||||
import 'package:win_text_editor/app/widgets/tab_bar.dart'; |
||||
|
||||
import '../models/editor_tab.dart'; |
||||
|
||||
class EditorPane extends StatelessWidget { |
||||
const EditorPane({super.key}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final editorProvider = Provider.of<EditorProvider>(context); |
||||
|
||||
return Column( |
||||
children: [ |
||||
const EditorTabBar(), |
||||
Expanded( |
||||
child: IndexedStack( |
||||
index: editorProvider.currentLayout, |
||||
children: [ |
||||
// 平铺布局 |
||||
GridView.builder( |
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2), |
||||
itemCount: editorProvider.openTabs.length, |
||||
itemBuilder: (context, index) { |
||||
return EditorTabContent(tab: editorProvider.openTabs[index]); |
||||
}, |
||||
), |
||||
// 层叠布局 |
||||
Stack( |
||||
children: |
||||
editorProvider.openTabs.map((tab) { |
||||
return Positioned( |
||||
top: 20.0 * editorProvider.openTabs.indexOf(tab), |
||||
left: 20.0 * editorProvider.openTabs.indexOf(tab), |
||||
right: 20.0 * editorProvider.openTabs.indexOf(tab), |
||||
bottom: 20.0 * editorProvider.openTabs.indexOf(tab), |
||||
child: EditorTabContent(tab: tab), |
||||
); |
||||
}).toList(), |
||||
), |
||||
// 单页布局 |
||||
if (editorProvider.openTabs.isNotEmpty) |
||||
EditorTabContent(tab: editorProvider.openTabs[editorProvider.activeTabIndex]), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
||||
|
||||
class EditorTabContent extends StatelessWidget { |
||||
final EditorTab tab; |
||||
|
||||
const EditorTabContent({super.key, required this.tab}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final editorProvider = Provider.of<EditorProvider>(context, listen: false); |
||||
|
||||
return Card( |
||||
margin: const EdgeInsets.all(8.0), |
||||
child: Column( |
||||
children: [ |
||||
Padding( |
||||
padding: const EdgeInsets.all(8.0), |
||||
child: Row( |
||||
children: [ |
||||
Text(tab.title), |
||||
const Spacer(), |
||||
IconButton( |
||||
icon: const Icon(Icons.content_copy), |
||||
onPressed: () => editorProvider.copyToClipboard(tab.id), |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.save), |
||||
onPressed: () => editorProvider.saveFile(tab.id), |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.close), |
||||
onPressed: () => editorProvider.closeTab(tab.id), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Expanded( |
||||
child: SingleChildScrollView( |
||||
child: Container(padding: const EdgeInsets.all(8.0), child: Text(tab.content)), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
|
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
|
||||
import '../models/file_node.dart'; |
||||
import '../providers/file_provider.dart'; |
||||
import '../providers/editor_provider.dart'; |
||||
|
||||
class FileExplorer extends StatefulWidget { |
||||
const FileExplorer({super.key}); |
||||
|
||||
@override |
||||
State<FileExplorer> createState() => _FileExplorerState(); |
||||
} |
||||
|
||||
class _FileExplorerState extends State<FileExplorer> { |
||||
final ScrollController _scrollController = ScrollController(); // 添加这行 |
||||
|
||||
@override |
||||
void dispose() { |
||||
_scrollController.dispose(); // 记得释放资源 |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
} |
||||
|
||||
Future<void> _promptForDirectory(BuildContext context) async { |
||||
final fileProvider = Provider.of<FileProvider>(context, listen: false); |
||||
final String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); |
||||
|
||||
if (selectedDirectory != null) { |
||||
await fileProvider.setRootPath(selectedDirectory); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final fileProvider = Provider.of<FileProvider>(context); |
||||
|
||||
return Container( |
||||
color: Colors.grey[200], |
||||
child: Column( |
||||
children: [ |
||||
Padding( |
||||
padding: const EdgeInsets.all(4.0), |
||||
child: Row( |
||||
children: [ |
||||
Expanded( |
||||
child: TextField( |
||||
decoration: const InputDecoration( |
||||
hintText: 'Search files...', |
||||
prefixIcon: Icon(Icons.search), |
||||
isDense: true, |
||||
), |
||||
onChanged: (value) => fileProvider.searchFiles(value), |
||||
), |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.folder_open), // 改为文件夹打开图标 |
||||
onPressed: () => _promptForDirectory(context), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
Expanded( |
||||
child: Scrollbar( |
||||
controller: _scrollController, // 添加控制器绑定 |
||||
thumbVisibility: true, // 始终显示滚动条 |
||||
interactive: true, // 启用滚动条拖动交互 |
||||
child: |
||||
fileProvider.isLoading |
||||
? const Center(child: CircularProgressIndicator()) |
||||
: fileProvider.fileNodes.isEmpty |
||||
? Center( |
||||
child: Column( |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
const Text('No directory selected'), |
||||
TextButton( |
||||
onPressed: () => _promptForDirectory(context), |
||||
child: const Text('Open Folder'), |
||||
), |
||||
], |
||||
), |
||||
) |
||||
: ListView.builder( |
||||
controller: _scrollController, // 确保ListView使用相同的控制器 |
||||
itemCount: _countVisibleNodes(fileProvider.fileNodes), |
||||
itemBuilder: (context, index) { |
||||
final node = _getVisibleNode(fileProvider.fileNodes, index); |
||||
return _FileNodeWidget( |
||||
key: ValueKey(node.path), |
||||
node: node, |
||||
onTap: () async { |
||||
if (node.isDirectory) { |
||||
await fileProvider.loadDirectoryContents(node); |
||||
} |
||||
}, |
||||
); |
||||
}, |
||||
), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
// 辅助方法:计算所有可见节点数量 |
||||
int _countVisibleNodes(List<FileNode> nodes) { |
||||
int count = 0; |
||||
for (final node in nodes) { |
||||
count++; |
||||
if (node.isDirectory && node.isExpanded) { |
||||
count += _countVisibleNodes(node.children); |
||||
} |
||||
} |
||||
return count; |
||||
} |
||||
|
||||
// 辅助方法:根据索引获取可见节点 |
||||
FileNode _getVisibleNode(List<FileNode> nodes, int index) { |
||||
int current = 0; |
||||
for (final node in nodes) { |
||||
if (current == index) return node; |
||||
current++; |
||||
if (node.isDirectory && node.isExpanded) { |
||||
final childCount = _countVisibleNodes(node.children); |
||||
if (index - current < childCount) { |
||||
return _getVisibleNode(node.children, index - current); |
||||
} |
||||
current += childCount; |
||||
} |
||||
} |
||||
throw Exception('Index out of bounds: $index (max: ${current - 1})'); |
||||
} |
||||
|
||||
class _FileNodeWidget extends StatelessWidget { |
||||
final FileNode node; |
||||
final VoidCallback onTap; |
||||
|
||||
const _FileNodeWidget({ |
||||
Key? key, // 声明 key 参数 |
||||
required this.node, |
||||
required this.onTap, |
||||
}) : super(key: key); // 传递给父类 |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final editorProvider = Provider.of<EditorProvider>(context, listen: false); |
||||
|
||||
return InkWell( |
||||
onTap: () { |
||||
if (node.isDirectory) { |
||||
onTap(); |
||||
} else { |
||||
editorProvider.openFile(node.path); |
||||
} |
||||
}, |
||||
child: Container( |
||||
padding: const EdgeInsets.symmetric(vertical: 0), // 缩小垂直间距 |
||||
child: ListTile( |
||||
dense: true, // 启用紧凑模式 |
||||
visualDensity: const VisualDensity( |
||||
vertical: -4, // 负值减少高度,正值增加高度 |
||||
), |
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 2), // 调整水平内边距 |
||||
minVerticalPadding: 0, // 最小垂直内边距设为0 |
||||
leading: _buildLeadingWidget(context), |
||||
title: Text( |
||||
node.name, |
||||
style: Theme.of(context).textTheme.bodyMedium, // 使用标准文本样式 |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildLeadingWidget(BuildContext context) { |
||||
return Row( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
||||
// Indentation lines |
||||
...List.generate(node.depth, (index) { |
||||
return Padding( |
||||
padding: const EdgeInsets.only(left: 6, right: 6.0), |
||||
child: Container(width: 1.0, height: 32.0, color: Colors.grey[500]), |
||||
); |
||||
}), |
||||
node.isDirectory |
||||
? Icon( |
||||
node.isExpanded ? Icons.expand_more : Icons.chevron_right, |
||||
color: Colors.cyan[200], |
||||
size: 20, // 缩小图标尺寸 |
||||
) |
||||
: const Icon(null, size: 2), |
||||
node.isDirectory |
||||
? Icon(Icons.folder, color: Colors.cyan[500], size: 16) |
||||
: Icon(Icons.file_open, color: Colors.amber[700], size: 16), |
||||
], |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/app/providers/editor_provider.dart'; |
||||
|
||||
import '../providers/file_provider.dart'; |
||||
|
||||
class EditorTabBar extends StatelessWidget { |
||||
const EditorTabBar({super.key}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final editorProvider = Provider.of<EditorProvider>(context); |
||||
|
||||
return Container( |
||||
height: 40, |
||||
color: Colors.grey[300], |
||||
child: Row( |
||||
children: [ |
||||
// 布局切换按钮 |
||||
PopupMenuButton<int>( |
||||
icon: const Icon(Icons.grid_view), |
||||
itemBuilder: |
||||
(context) => [ |
||||
const PopupMenuItem(value: 0, child: Text('平铺布局')), |
||||
const PopupMenuItem(value: 1, child: Text('层叠布局')), |
||||
const PopupMenuItem(value: 2, child: Text('单页布局')), |
||||
], |
||||
onSelected: (value) => editorProvider.changeLayout(value), |
||||
), |
||||
// 标签页 |
||||
Expanded( |
||||
child: ListView.builder( |
||||
scrollDirection: Axis.horizontal, |
||||
itemCount: editorProvider.openTabs.length, |
||||
itemBuilder: (context, index) { |
||||
final tab = editorProvider.openTabs[index]; |
||||
return InkWell( |
||||
onTap: () => editorProvider.setActiveTab(index), |
||||
child: Container( |
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0), |
||||
decoration: BoxDecoration( |
||||
color: |
||||
editorProvider.activeTabIndex == index ? Colors.white : Colors.grey[200], |
||||
border: Border( |
||||
bottom: BorderSide( |
||||
color: |
||||
editorProvider.activeTabIndex == index |
||||
? Colors.blue |
||||
: Colors.transparent, |
||||
width: 2.0, |
||||
), |
||||
), |
||||
), |
||||
child: Center( |
||||
child: Row( |
||||
children: [ |
||||
Text(tab.title), |
||||
const SizedBox(width: 8), |
||||
IconButton( |
||||
icon: const Icon(Icons.close, size: 16), |
||||
onPressed: () => editorProvider.closeTab(tab.id), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
); |
||||
}, |
||||
), |
||||
), |
||||
// 打开文件按钮 |
||||
IconButton( |
||||
icon: const Icon(Icons.add), |
||||
onPressed: () async { |
||||
final fileProvider = Provider.of<FileProvider>(context, listen: false); |
||||
await fileProvider.pickAndOpenFile(); |
||||
}, |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
} |
@ -1,122 +1,38 @@
@@ -1,122 +1,38 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:window_manager/window_manager.dart'; |
||||
import 'app/app.dart'; |
||||
|
||||
void main() async { |
||||
WidgetsFlutterBinding.ensureInitialized(); |
||||
|
||||
// 配置窗口 |
||||
await windowManager.ensureInitialized(); |
||||
WindowOptions windowOptions = const WindowOptions( |
||||
size: Size(1200, 800), |
||||
center: true, |
||||
title: 'Win Text Editor', |
||||
); |
||||
windowManager.waitUntilReadyToShow(windowOptions, () async { |
||||
await windowManager.show(); |
||||
await windowManager.focus(); |
||||
}); |
||||
|
||||
void main() { |
||||
runApp(const MyApp()); |
||||
} |
||||
|
||||
class MyApp extends StatelessWidget { |
||||
const MyApp({super.key}); |
||||
|
||||
// This widget is the root of your application. |
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return MaterialApp( |
||||
title: 'Flutter Demo', |
||||
title: '文本编辑器', |
||||
debugShowCheckedModeBanner: false, |
||||
theme: ThemeData( |
||||
// This is the theme of your application. |
||||
// |
||||
// TRY THIS: Try running your application with "flutter run". You'll see |
||||
// the application has a purple toolbar. Then, without quitting the app, |
||||
// try changing the seedColor in the colorScheme below to Colors.green |
||||
// and then invoke "hot reload" (save your changes or press the "hot |
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used |
||||
// the command line to start the app). |
||||
// |
||||
// Notice that the counter didn't reset back to zero; the application |
||||
// state is not lost during the reload. To reset the state, use hot |
||||
// restart instead. |
||||
// |
||||
// This works for code too, not just values: Most code changes can be |
||||
// tested with just a hot reload. |
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), |
||||
), |
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'), |
||||
); |
||||
} |
||||
} |
||||
|
||||
class MyHomePage extends StatefulWidget { |
||||
const MyHomePage({super.key, required this.title}); |
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning |
||||
// that it has a State object (defined below) that contains fields that affect |
||||
// how it looks. |
||||
|
||||
// This class is the configuration for the state. It holds the values (in this |
||||
// case the title) provided by the parent (in this case the App widget) and |
||||
// used by the build method of the State. Fields in a Widget subclass are |
||||
// always marked "final". |
||||
|
||||
final String title; |
||||
|
||||
@override |
||||
State<MyHomePage> createState() => _MyHomePageState(); |
||||
} |
||||
|
||||
class _MyHomePageState extends State<MyHomePage> { |
||||
int _counter = 0; |
||||
|
||||
void _incrementCounter() { |
||||
setState(() { |
||||
// This call to setState tells the Flutter framework that something has |
||||
// changed in this State, which causes it to rerun the build method below |
||||
// so that the display can reflect the updated values. If we changed |
||||
// _counter without calling setState(), then the build method would not be |
||||
// called again, and so nothing would appear to happen. |
||||
_counter++; |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
// This method is rerun every time setState is called, for instance as done |
||||
// by the _incrementCounter method above. |
||||
// |
||||
// The Flutter framework has been optimized to make rerunning build methods |
||||
// fast, so that you can just rebuild anything that needs updating rather |
||||
// than having to individually change instances of widgets. |
||||
return Scaffold( |
||||
appBar: AppBar( |
||||
// TRY THIS: Try changing the color here to a specific color (to |
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar |
||||
// change color while the other colors stay the same. |
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary, |
||||
// Here we take the value from the MyHomePage object that was created by |
||||
// the App.build method, and use it to set our appbar title. |
||||
title: Text(widget.title), |
||||
), |
||||
body: Center( |
||||
// Center is a layout widget. It takes a single child and positions it |
||||
// in the middle of the parent. |
||||
child: Column( |
||||
// Column is also a layout widget. It takes a list of children and |
||||
// arranges them vertically. By default, it sizes itself to fit its |
||||
// children horizontally, and tries to be as tall as its parent. |
||||
// |
||||
// Column has various properties to control how it sizes itself and |
||||
// how it positions its children. Here we use mainAxisAlignment to |
||||
// center the children vertically; the main axis here is the vertical |
||||
// axis because Columns are vertical (the cross axis would be |
||||
// horizontal). |
||||
// |
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" |
||||
// action in the IDE, or press "p" in the console), to see the |
||||
// wireframe for each widget. |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: <Widget>[ |
||||
const Text('You have pushed the button this many times:'), |
||||
Text( |
||||
'$_counter', |
||||
style: Theme.of(context).textTheme.headlineMedium, |
||||
), |
||||
], |
||||
), |
||||
primarySwatch: Colors.blue, |
||||
visualDensity: VisualDensity.adaptivePlatformDensity, |
||||
), |
||||
floatingActionButton: FloatingActionButton( |
||||
onPressed: _incrementCounter, |
||||
tooltip: 'Increment', |
||||
child: const Icon(Icons.add), |
||||
), // This trailing comma makes auto-formatting nicer for build methods. |
||||
home: const AppScaffold(), |
||||
); |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue