Flet 资源管理器
一个用 Python + Flet(https://flet.dev) (基于 Flutter) 实现的 Windows 资源管理器仿制品。 支持多标签页、完整的导航历史、文件操作、右键菜单、键盘快捷键,UI 完全声明式,跨平台。 - 核心概念:单向数据流(#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5%EF%BC%9A%E5%8D%95%E5%90%91%E6%95%B0%E6%8D%AE%E6%B5%81) - 状态层 (state.py)(#%E7%8A%B6%E6%80%81%E5%B1%82-statepy)
一个用 Python + Flet (基于 Flutter) 实现的 Windows 资源管理器仿制品。
支持多标签页、完整的导航历史、文件操作、右键菜单、键盘快捷键,UI 完全声明式,跨平台。
目录
快速开始
环境要求
- Python 3.10+
- Windows / macOS / Linux(驱动器列表只在 Windows 显示盘符)
- uv(推荐)或 pip
安装并运行
# 克隆/拷贝到本地
cd D:\code\python\side\flet_explorer
# 安装依赖(首次大约 1-2 分钟,要下载 flet-desktop ~30MB)
uv sync
# 启动
uv run python main.py
指定启动目录
$env:FLET_EXPLORER_INITIAL = "D:\my\folder"
uv run python main.py
未设置时默认打开 "此电脑"。
Web 模式
uv run flet run --web --port 8765 main.py
注意:web 模式使用 Pyodide 在浏览器里跑 Python,本项目用了 psutil + pywin32,Web 模式无法工作。如需 web 版本要重写文件系统层。
功能清单
✅ 已实现
| 类别 | 功能 |
|---|---|
| 标签页 | 新建、关闭、切换;每个标签独立保留路径/历史/选择/视图模式 |
| 导航 | 后退、前进、向上、刷新;完整的历史栈(含 forward 分支截断) |
| 地址栏 | 可点击的面包屑跳转;点击空白处切换为文本编辑模式直接输入路径 |
| 侧边栏 | 此电脑、快速访问(主文件夹/桌面/下载/文档/图片/音乐/视频)、所有驱动器 |
| 侧边栏长按 | 长按任意条目在新标签页打开 |
| 此电脑视图 | 所有驱动器以磁贴呈现,带容量进度条(>90% 红 / >75% 黄 / 其他蓝) |
| 文件浏览 | 三种视图:详细信息 / 列表 / 大图标 |
| 排序 | 按名称 / 修改日期 / 类型 / 大小,升降序切换,点击列标题切换 |
| 选择 | 单击单选,Ctrl+点击多选,Ctrl+A 全选 |
| 打开 | 双击文件夹进入,双击文件以系统默认程序打开 |
| 文件操作 | 新建文件夹/文本文档、剪切、复制、粘贴、重命名、删除(含递归删文件夹) |
| 冲突处理 | 自动追加 " - 副本 (N)" 后缀 |
| 对话框 | 新建文件夹、重命名、删除确认(带文件名预览) |
| 搜索 | 当前路径递归搜索(限 500 条结果),结果直接显示 |
| 隐藏文件 | 切换显示/隐藏(含 Windows 的 FILE_ATTRIBUTE_HIDDEN) |
| 右键菜单 | 文件行右键菜单:打开/剪切/复制/重命名/删除,文件夹多 "在新标签页打开" |
| 键盘快捷键 | F5/F2/Del/ESC/Backspace、Ctrl+C/X/V/T/W/A/N 全套 |
| 打开系统资源管理器 | 一键跳到系统的 explorer.exe |
| 状态栏 | 项目数、选中数、剪贴板状态、最近操作消息 |
🚧 未实现(可作为练手扩展)
- 文件缩略图(图片/视频预览)
- 拖拽移动/复制
- 树形侧边栏展开
- 文件属性详情对话框
- Shift+点击范围选择
- 文件搜索全局后台索引
- 历史记录持久化
- 主题切换 UI
键盘快捷键
| 快捷键 | 动作 |
|---|---|
| F5 | 刷新当前目录 |
| F2 | 重命名(单选时) |
| Delete | 删除选中项(带确认) |
| Backspace | 后退 |
| Esc | 关闭所有对话框 + 取消选择 |
| Ctrl+C | 复制选中项到剪贴板 |
| Ctrl+X | 剪切选中项 |
| Ctrl+V | 粘贴到当前目录 |
| Ctrl+A | 全选当前目录 |
| Ctrl+T | 新建标签页 |
| Ctrl+W | 关闭当前标签页(至少保留 1 个) |
| Ctrl+N | 新建文件夹(当前目录非"此电脑"时) |
项目结构
flet_explorer/
├── main.py ← UI 组件 + main() 入口(约 1100 行)
├── state.py ← AppState / TabState 观察者模型(约 230 行)
├── fs_utils.py ← 文件系统操作(纯函数 + dataclass)
├── tests_state.py ← 状态层单元测试
├── tests_full.py ← 端到端测试(含真实文件系统操作)
├── snap.py ← 调试用:窗口截图工具
├── pyproject.toml ← uv/PEP 621 项目配置
├── uv.lock ← 锁文件
├── README.md ← 本文档
└── .venv/ ← uv 虚拟环境(自动生成)
依赖关系:单向,main.py → state.py → fs_utils.py。
fs_utils.py 不 import 任何 Flet 或 state 的东西 —— 它是纯 Python,可独立复用。
架构与设计
核心概念:单向数据流
┌──────────────────────────┐
│ │
▼ │
┌──────────────┐ │
│ AppState │ ← 唯一可信源 │
│ (Observable) │ │
└──────┬───────┘ │
│ notify │ mutate
│ (订阅者收到) │ via state.method()
▼ │
┌──────────────┐ │
│ Components │ ──── on_click ─────┘
│ (@ft.component)│
└──────────────┘
│
▼
Flutter
(UI 渲染)
核心规则(违反任一条,UI 就不刷新):
- 所有状态都在
AppState里(包括对话框开关、文本草稿) - 组件不直接 mutate 状态,只调用
state.xxx()方法 - 每个 state 方法末尾必须触发 state 级通知(直接修改 state 的字段就行,或调
_bump()) - 组件订阅传入的 Observable args/kwargs,不订阅内部访问的嵌套对象
状态层 (state.py)
TabState — 单标签页状态
@ft.observable
@dataclass
class TabState:
path: str = THIS_PC
history: list[str] = field(default_factory=lambda: [THIS_PC])
history_index: int = 0
view_mode: str = "details" # details | list | icons
sort_by: str = "name"
sort_desc: bool = False
selected: set[str] = field(default_factory=set)
search_query: str = ""
refresh_token: int = 0 # 强制 FolderView 重读目录
只持有数据。can_go_back() / can_go_forward() / title 是只读派生。
AppState — 全局状态 + 操作方法
@ft.observable
@dataclass
class AppState:
tabs: list[TabState] = field(default_factory=lambda: [TabState()])
active_index: int = 0
clipboard_paths: list[str] = field(default_factory=list)
clipboard_op: str = "copy" # copy | cut
show_hidden: bool = False
status_message: str = ""
rename_target: str = "" # 非空 → 显示重命名对话框
rename_draft: str = ""
delete_targets: list[str] = field(default_factory=list)
new_folder_open: bool = False
new_folder_draft: str = "新建文件夹"
tick: int = 0 # 由 _bump() 自增触发通知
所有方法都在 AppState 上:
- 导航:
navigate(path)/go_back()/go_forward()/go_up()/refresh() - 标签:
new_tab(path)/close_tab(i)/activate(i) - 选择:
toggle_select(path, ctrl)/select_only(path)/clear_selection()/select_all(paths) - 视图:
set_view_mode(mode)/set_sort_by(by)/toggle_sort_desc()/toggle_show_hidden() - 搜索:
set_search(q)/clear_search() - 剪贴板:
set_clipboard(paths, op)/clear_clipboard() - 对话框触发:
request_rename()/request_delete()/request_new_folder()/close_dialogs()
每个 tab 变更方法都以 self._bump() 结尾,触发 state 级通知,所有订阅了 state 的组件都会重渲染。
文件系统层 (fs_utils.py)
纯函数 + 数据类。零 Flet 依赖。
数据类
@dataclass(frozen=True)
class FileEntry:
name: str
path: str
is_dir: bool
size: int
modified: float
ext: str
# 派生属性
@property
def size_str(self) -> str: ... # "1.5 MB"
@property
def modified_str(self) -> str: ... # "2026/05/23 04:56"
@property
def type_str(self) -> str: ... # "文件夹" / "PY 文件"
@dataclass(frozen=True)
class DriveInfo:
letter: str
label: str
total: int
used: int
free: int
fstype: str
@property
def percent(self) -> float: ... # 已使用比例 0-1
核心函数
# 目录浏览
list_directory(path, show_hidden=False) -> list[FileEntry]
sort_entries(entries, by="name", desc=False) -> list[FileEntry] # 文件夹永远在前
# 驱动器
get_drives() -> list[DriveInfo] # Windows 用 psutil 枚举,其他平台用挂载点
# 路径辅助
parent_path(path) -> str | None # "C:\" 的父级是 THIS_PC
path_segments(path) -> list[tuple[str, str]] # 给面包屑用
is_this_pc(path) -> bool
get_quick_access() -> list[tuple[label, path, icon_name]]
# 文件操作(都返回 (ok, ...) 错误友好)
create_folder(parent, name) -> (ok, path, err)
create_file(parent, name) -> (ok, path, err)
rename_path(old, new_name) -> (ok, new_path, err)
copy_paths(sources, dest_dir) -> (count, errors)
move_paths(sources, dest_dir) -> (count, errors)
delete_path(path) -> (ok, err) # 递归删除文件夹
unique_destination(target) -> str # 解决重名
# 搜索
search_directory(root, query, max_results=500) -> list[FileEntry]
# 系统集成
open_with_default_app(path)
open_in_system_explorer(path)
# 格式化
format_size(num_bytes) -> str # "1.5 MB"
UI 层 (main.py)
完全声明式,每个 UI 块是一个 @ft.component 函数。
组件树
Root(state)
└── Container (bgcolor="#1e272e", expand=True)
└── Column
├── TabBar(state)
│ └── Row [TabChip x N, IconButton "+"]
├── NavToolbar(state)
│ └── Row [back, forward, up, refresh, BreadcrumbBar, search field]
├── ActionToolbar(state)
│ └── Row [新建▼, cut, copy, paste, rename, delete, 排序▼, 查看▼, 启动]
├── Row (expand=True)
│ ├── Sidebar(state)
│ ├── VerticalDivider
│ └── ContentArea(state)
│ ├── ThisPCView(state) ← path == THIS_PC
│ │ └── Row [DriveTile x N]
│ └── FolderView(state) ← 否则
│ ├── DetailsHeader(state) ← view_mode == details
│ └── Column [FileRow x N]
├── StatusBar(state)
└── DialogHost(state) ← 渲染当前活动对话框
每个组件订阅什么
只要把 state 作为 kwarg 传入,组件就会自动订阅 state 上的所有字段变化。这就是为什么所有 mutation 都得过 state —— 让 state 知道,从而通知订阅者。
API 参考
state.py 完整 API
# 创建状态
s = AppState()
# 导航
s.navigate("D:\\code") # 跳转到路径
s.go_back() # 后退(如果历史允许)
s.go_forward() # 前进
s.go_up() # 上一级目录
s.refresh() # 重新读取当前目录
# 标签管理
s.new_tab("D:\\") # 新建标签,自动激活
s.close_tab(0) # 关闭索引 0 的标签(至少留一个)
s.activate(2) # 切换到标签 2
# 选择
s.toggle_select("path/to/file") # 单选
s.toggle_select("path", ctrl=True) # 多选/取消
s.select_only("path") # 重置选择为单个
s.clear_selection()
s.select_all([paths])
# 视图
s.set_view_mode("icons") # details | list | icons
s.set_sort_by("size") # 同 key 再次调用切换升降序
s.toggle_sort_desc()
s.toggle_show_hidden()
# 搜索
s.set_search("foo") # 触发当前目录递归搜索
s.clear_search()
# 剪贴板
s.set_clipboard(["a.txt", "b.py"], "copy")
s.clear_clipboard()
# 对话框(设置即触发显示)
s.request_rename() # 需要选中恰好 1 项
s.request_delete() # 需要至少 1 项选中
s.request_new_folder() # 需要非 "此电脑"
s.close_dialogs() # 关闭所有对话框
# 当前活动 tab(属性)
s.active # → TabState
s.active.path # 当前路径
s.active.selected # 选中的 set[str]
s.active.history # 历史栈
Flet 0.85 已知坑与解法
这部分是本项目最有价值的实战经验。逐条都是真实踩过的坑。
1. Observable 只订阅 args/kwargs
症状:mutate 嵌套对象,UI 不刷新。
# ❌ 失败:tab 不在订阅图里
def NavToolbar(state):
tab = state.active
return ft.IconButton(on_click=lambda: tab.path = "X")
解法:所有变更走 state 方法 + state 字段写入触发通知。
2. 下划线前缀字段不通知
Flet observable 源码:if name.startswith("_"): no notify。
❌ _tick、_counter ✅ tick、counter
3. lambda 默认参数被事件参数顶掉
# ❌ Flet 把 event 当成第一个位置参数 → dest = Event(...)
on_click=lambda dest=target: state.navigate(dest)
# ✅ 显式吃事件
on_click=lambda _e=None, dest=target: state.navigate(dest)
4. use_dialog IndexError
File "use_dialog.py", line 98
prev_lists["controls"][prev_idx] = dialog
IndexError: list assignment index out of range
触发条件:
- 同一组件多次
use_dialog - 重新渲染时把新的 AlertDialog 实例传给
use_dialog
解法(必须两步同时上):
# 1. 一个组件只 use_dialog 一次
mode = "delete" if dts else "rename" if rt else "newfolder" if nf else None
# 2. 用 use_memo 让 dialog 实例在同 mode 内稳定
dialog = ft.use_memo(build_dialog, dependencies=[mode])
ft.use_dialog(dialog)
5. memo'd dialog 的闭包陈旧
use_memo 缓存了对话框 → 里面 lambda 捕获的 state 是首次渲染时的快照 → 用户输入后 confirm 拿不到新值。
解法:把草稿提升到 AppState,lambda 直接读 state:
ft.TextField(
value=state.new_folder_draft,
on_change=lambda e: setattr(state, "new_folder_draft", e.control.value),
)
def confirm():
name = state.new_folder_draft.strip() # 总是读最新
6. ContextMenu 的 items 不被右键触发
# ❌ items 需要手动 await ctx.open()
ContextMenu(items=[...], secondary_trigger=...)
# ✅ 右键自动触发要用 secondary_items
ContextMenu(secondary_items=[PopupMenuItem(...)], secondary_trigger=...)
而且 secondary_items 里必须是 PopupMenuItem,塞 ft.Text 不报错但点击无效。
7. PopupMenuItem 没有 text 参数
Flet 0.85 把 text=... 移除了,必须用 content:
# ❌ Flet < 0.80
PopupMenuItem(text="文件夹", icon=ft.Icons.FOLDER)
# ✅ Flet 0.85
PopupMenuItem(content=ft.Row([ft.Icon(ft.Icons.FOLDER), ft.Text("文件夹")]))
8. Container 没有 on_double_tap
字段不存在,赋值会报 unexpected keyword argument。
# ✅ 用 GestureDetector
ft.GestureDetector(
content=container_body,
on_tap=on_select,
on_double_tap=on_open,
)
9. Flutter 桌面窗口的 GDI 截屏全灰
Flutter 用 DirectComposition,普通 mss / ImageGrab 截不到。
# ✅ include_layered_windows=True 是救命参数
ImageGrab.grab(bbox=rect, all_screens=True, include_layered_windows=True)
10. Web 模式 = Pyodide
flet run --web 把 Python 编译到浏览器(Pyodide)。psutil、pywin32 不可用。本项目只支持桌面。要 web 部署需另写一个浏览器友好的文件系统层。
测试
单元测试(状态层)
uv run python tests_state.py
覆盖:tab navigation、history truncation、tabs、selection、view/sort、fs operations、drives、path segments、notification。
端到端测试
uv run python tests_full.py
覆盖:完整的 AppState 操作流程 + 真实文件系统操作(在临时目录里)。
手动测试沙盒
# 1. 创建沙盒
New-Item -ItemType Directory -Path .\sandbox -Force
"hello" | Out-File .\sandbox\test.txt -NoNewline
New-Item -ItemType Directory -Path .\sandbox\subdir -Force
# 2. 启动 app 直接打开沙盒
$env:FLET_EXPLORER_INITIAL = "$pwd\sandbox"
uv run python main.py
# 3. 测完清理
Remove-Item .\sandbox -Recurse -Force
截图工具
# 截当前 Flet 窗口
uv run python snap.py
# 移动到固定位置再截
uv run python snap.py --move
输出到 screenshot.png。
开发指南:如何扩展
添加新的操作
举例:实现"复制完整路径"功能。
1. 在 fs_utils.py 加纯函数(如果需要):
def copy_to_clipboard_text(text: str) -> None:
import subprocess
subprocess.run(["clip"], input=text.encode("utf-16le"), check=False)
2. 在 AppState 加方法:
def copy_path_to_clipboard(self) -> None:
if not self.active.selected:
return
text = "\n".join(self.active.selected)
fsu.copy_to_clipboard_text(text)
self.status_message = "已复制完整路径"
3. 在 UI 加按钮(如 ActionToolbar 或右键菜单):
ft.IconButton(
icon=ft.Icons.LINK,
tooltip="复制完整路径",
disabled=not has_sel,
on_click=lambda: state.copy_path_to_clipboard(),
)
4. 测试 —— 在 tests_full.py 加用例。
添加新的对话框
由于 Flet 0.85 use_dialog 的限制,不要再 ft.use_dialog,而是扩展 DialogHost 的 mode:
# 在 state.py
class AppState:
properties_target: str = "" # 新增字段
def request_properties(self) -> None:
if len(self.active.selected) == 1:
self.properties_target = next(iter(self.active.selected))
# 在 main.py DialogHost
mode = ("delete" if dts
else "rename" if rt
else "newfolder" if nf_open
else "properties" if state.properties_target # ← 新增
else None)
def build_dialog():
...
if mode == "properties":
return ft.AlertDialog(...)
添加新的视图模式
TabState.view_mode已是str,扩展可选值- 在
FolderView里加if tab.view_mode == "new_mode":分支 - 在
ActionToolbar的view_menu_items加菜单项
添加新的快捷键
直接在 make_keyboard_handler() 里加 elif:
elif ctrl and shift and key == "n": # Ctrl+Shift+N
state.request_new_folder()
性能优化建议
- 大目录列表(>1000 项):用
ft.ListView(controls=rows)替代ft.Column(controls=rows),自动虚拟化 - 搜索:移到
page.run_thread异步执行 - 缩略图:用 LRU 缓存,避免重复读图
故障排查
| 症状 | 可能原因 | 解决 |
|---|---|---|
| 窗口启动后白屏/纯灰 | 渲染异常,多半是观察者 bug | 看终端 stderr,搜 IndexError |
| 点击按钮没反应 | lambda dest=... 捕获被事件顶掉 |
改 lambda _e=None, dest=... |
| 对话框打开第二次崩溃 | use_dialog 内部 bug |
用 use_memo 缓存 dialog 身份 |
| 输入框输入完确认没生效 | dialog 被 memo,闭包陈旧 | 把草稿放 state |
| 文件名乱码 | 终端编码问题 | PowerShell 不需要管,Python 内部都 UTF-8 |
| 启动很慢(>10s) | 首次加载 Flutter desktop runtime | 正常,后续启动 ~2s |
| Web 模式启动后空白 | Pyodide 不支持 psutil/win32 | 用桌面模式 |
| 截图全灰 | Flutter GPU 渲染 GDI 截不到 | 用 include_layered_windows=True |
| 鼠标点击错位 | DPI 缩放或坐标系混淆 | snap.py 用物理像素,windows-mcp 也用物理像素,别混 |
启用 Flet 调试日志
$env:FLET_LOG_LEVEL = "debug"
uv run python main.py 2>&1 | Tee-Object run.log
验证状态没问题
如果 UI 不正常但你不确定是状态还是渲染问题,跑端到端测试:
uv run python tests_full.py
通过 = 状态层 OK,问题在 UI 绑定。失败 = 状态层有 bug。
依赖与版本
运行时
# pyproject.toml
dependencies = [
"flet[all]>=0.85,<0.90",
"psutil>=7.0",
]
开发时(可选)
[dependency-groups]
dev = [
"mss",
"pywin32", # Windows 截图/窗口操作
"pillow",
"windows-capture", # 备用截图方案
]
测试通过的版本组合
- Python 3.12.13
- Flet 0.85.1
- Windows 11 Pro
- uv 0.5+
Flet 1.0 alpha API 大致兼容,但 use_dialog 内部实现可能改变,需重新测试本文件 "Flet 0.85 已知坑" 第 4-5 条。
项目获得的核心经验
- 响应式 UI 的本质是订阅图 —— 框架不会神奇地知道你读了什么,它只能跟踪它能"看见"的引用。
- 单一可信源(Single Source of Truth)让状态推理变简单 —— 全放 AppState,配合 Observer 模式。
- 不可变更新比就地修改更友好 ——
tabs = tabs + [x]比tabs.append(x)对 diff 算法更友好。 - 框架 bug 是常态 —— 准备好读源码(venv 里的
.py都是可读的)+ 写 workaround。 - 可测性 = 代码质量 ——
fs_utils全是纯函数,写测试一行实现一行测试。 - 桌面应用的 DPI 是一等公民 —— 别用裸 win32 坐标,先统一一套坐标系。
- 状态的"形状"决定渲染拓扑 —— 对话框状态放 AppState 还是组件本地,对 memo 行为影响巨大。
- GUI 自动化测试很脆 —— 优先写状态层测试,UI 用截图人工 review。
许可
MIT (随项目附带的 pyproject.toml 决定,可自行修改)