- 文件剪贴板复制剪切粘贴,重名自动副本 - SFTP下载到本机与外部文件拖入,目录递归 - 传输进度浮动面板,transfer-progress事件链 - Drawio预览,Excel工作线程,冻结看门狗 - cmd与bat自写batch语法高亮,psm1等映射补齐
45 KiB
45 KiB
功能决策记录
日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。 创建:2026-06-12 | 维护:随开发追加
来源:历史会话挖掘 + 当前会话。覆盖 u-desk 0.3.x → 0.4.x(fs-only-v3)开发期。
启动 / 关闭速度
ServiceStartup 非关键路径并行化
- 决策:SQLite + ConfigAPI 前置同步,PdfAPI/FileSystemService/CleanupExpiredCache/CleanupSiteCacheDirs 移入 goroutine 并行。
- 原因/取舍:前置依赖必须先就绪;非关键子系统串行会阻塞 Wails 窗口加载前端,并行后首屏不受拖累。
- 状态:✅ 已落地(
app.goServiceStartup)
审计日志懒初始化 ensureReady
- 决策:审计日志从启动全量初始化改为首条写入时才
ensureReady(开文件 + 起 flush 协程)。 - 原因/取舍:原
NewAuditLogger启动即建目录/开文件/起协程拖慢启动。懒初始化零启动开销;用 mutex+initialized 标志替代sync.Once,因 sync.Once 消费后不再执行、初始化失败后后续 Log 静默丢失。 - 状态:✅ 已落地(
audit_log.go)
回收站 metadata 延迟加载
- 决策:回收站
metadata.json从启动加载改为首次访问时ensureLoaded。 - 原因/取舍:用户可能整个会话不用回收站;延迟加载减启动 IO 和内存。
- 状态:✅ 已落地(
recycle_bin.go)
startFileServer 同步启动
- 决策:
startFileServer从go a.startFileServer()改为同步调用。 - 原因/取舍:该函数只注册 HTTP handler 不阻塞;异步启动反而有 handler 未注册完成时前端已请求的竞态。
- 状态:✅ 已落地(
app_filesystem.go)
loadFromDB 延迟 500ms 自动连接
- 决策:
loadFromDB中系统信息采集 + 自动连接包进setTimeout(500)。 - 原因/取舍:远程 profile 自动连接并发网络 IO 阻塞首屏;延迟 500ms 让空壳 UI 先渲染。代价:首屏 500ms 内远程不可用,且与 session restore 存在同 profile 双连接竞争(见连接管理域)。
- 状态:✅ 已落地(
connection-manager.ts)
session restore 延迟到 rAF
- 决策:会话恢复(路径/连接)延迟到
requestAnimationFrame。 - 原因/取舍:
onMounted恢复阻塞 Vue 首帧渲染;rAF 让首帧先绘制再恢复。 - 状态:✅ 已落地(
index.vueonMounted)
Markdown / highlight.js 动态懒加载
- 决策:
marked+highlight.js从静态 import 改为按需动态 import。 - 原因/取舍:两库 ~200KB+,全量导入阻塞首屏;文件预览是按需功能不应拖慢所有用户。代价:首次开 MD 有原文闪烁。
- 状态:✅ 已落地(
markedExtensions.ts)
启动后 3s 延迟静默检查更新
- 决策:启动后
setTimeout(3000)静默检查更新。 - 原因/取舍:启动即发网络请求增加首屏时间;延迟 3s 让 UI 先渲染,静默不弹窗。
- 状态:✅ 已落地(
App.vue)
ServiceShutdown errgroup 并行 + 5s 总超时
- 决策:关闭用 errgroup 并行关 4 个子系统,共享 5s 总超时。
- 原因/取舍:原串行关闭耗时叠加;并行后总耗时取最慢子系统,5s 兜底防无限挂起。
- 状态:✅ 已落地(
app.goServiceShutdown)
热键轮询递增间隔
- 决策:热键轮询从固定 200ms 改为 50→100→200ms 递增。
- 原因/取舍:首检快 150ms(50ms 捕获),后续逐步放宽降 CPU。
- 状态:✅ 已落地(
app.go)
文件删除 / 回收站
回收站优先策略
- 决策:删除文件时先尝试移入回收站,移动失败才永久删除。
- 原因/取舍:旧逻辑「先永久删除再移回收站」——文件已删,移回收站必然失败,回收站形同虚设。改为先移后删保证误删可恢复。永久删除仅在跨卷/权限失败时降级兜底。
- 状态:✅ 已落地(
filesystem/service.go)
isInRecycleBin 用 HasPrefix
- 决策:判断路径是否在回收站内用
strings.HasPrefix而非切片前缀比较。 - 原因/取舍:旧
cleanPath[:len(cleanBinPath)]当len(cleanPath) < len(cleanBinPath)时切片越界 panic。HasPrefix 自带长度判断。 - 状态:✅ 已落地
回收站 entries 并发安全
- 决策:
RecycleBin.entries加sync.Mutex,ListEntries返回副本。 - 原因/取舍:
autoCleanup后台协程与用户操作并发读写 entries 存在竞争;返回副本避免外部遍历被 cleanup 修改 panic。 - 状态:✅ 已落地(
recycle_bin.go)
随机字符串加索引偏移
- 决策:
generateRandomString每个字符对 nanoseed 加索引i偏移再取模。 - 原因/取舍:同一次调用内多字符同纳秒生成、seed 相同导致全串重复字符。加索引打破局部相同。非密码学随机,简单偏移足够。
- 状态:✅ 已落地(
recycle_bin.go)
IsFileLocked 去 O_CREAT
- 决策:
IsFileLocked去掉O_CREAT改纯只读探测。 - 原因/取舍:
O_CREAT检查锁时会意外创建空文件——目标已删时被「复活」成 0 字节误导。纯探测不应有写入语义。 - 状态:✅ 已落地(
file_lock.go)
远程 HTML 站点预览
确定性站点缓存目录
- 决策:远程 HTML 预览用
common.SiteCacheDir(SHA-256 哈希 transport+connID+path+size+modTime)生成确定性路径,命中直接返回;SFTP 与 OSS 统一。 - 原因/取舍:旧 OSS 方案每次
os.MkdirTemp全量重下,二次打开仍慢。确定性哈希让同文件二次打开命中缓存,size+modTime 进哈希源文件变动自动失效。取舍:路径可预测(非随机名),去重收益远大于此。 - 状态:✅ 已落地(
common/utils.go+ossdrv/sftpDownloadSiteForPreview)
资源下载范围演进
- 决策:全量下载 → 仅补充已发现资源所在目录 →
supplementDir限制 50 文件 / 单文件 5MB。 - 原因/取舍:用户指出「网站可能 100G」全量不可行;改为只补充资源所在目录(webpack chunk 等)。最终加 50 文件 + 5MB 上限防失控,SFTP/OSS 统一。
- 状态:✅ 已落地(
sftp/service.go、ossdrv/service.go)
OSS 补充分页
- 决策:OSS
supplementDir列目录循环翻页(IsTruncated+NextMarker),取完或达上限止。 - 原因/取舍:旧
MaxKeys:200单次调用,目录超 200 文件直接截断漏下载。分页保完整 + 上限防失控。 - 状态:✅ 已落地(
ossdrv/service.go)
资源下载并发策略
- 决策:相对路径资源 8 并发、绝对路径资源串行。
- 原因/取舍:绝对路径 siteRoot 嗅探是共享状态需顺序探测;相对路径无依赖可并行。20 资源从 20×RTT 降到 ~3 轮;8 并发信号量防连接数爆炸。
- 状态:✅ 已落地(
sftp/ossdrvDownloadSiteForPreview)
绝对路径 siteRoot 向上嗅探
- 决策:绝对路径
/assets/js/main.js的根含义通过从 HTML 所在目录逐级向上尝试下载直到桶根来确定,非简单去/。 - 原因/取舍:不同 HTML 的
/可能指向不同根(多站点共存);简单相对 HTML 目录在嵌套场景出错(pages/about.html引/assets/应到桶根而非 pages/assets)。 - 状态:✅ 已落地(
ossdrvresolveAbsoluteResourcePath)
siteRoot 不跨 HTML 共享
- 决策:每个 HTML 独立嗅探 siteRoot,不跨调用共享。
- 原因/取舍:同桶多个不同网站的
/可能指不同根;共享会跨站污染。 - 状态:✅ 已落地
路径重写:正则 → JS setter 拦截
- 决策:用 JS 拦截脚本覆盖
HTMLScriptElement.src等 setter 重写绝对路径,替代locationPathRegex正则替换。 - 原因/取舍:正则替换
location.pathname在 inline JS 字符串内破坏引号致 SyntaxError;正则无法处理 webpack 动态import()chunk。setter 拦截覆盖静态 + 动态两种。 - 状态:✅ 已落地(
asset_handler.goinjectPathInterceptor)
拦截脚本 base 用 baseDir
- 决策:拦截脚本 base 直接用 HTML 文件所在目录,不用
findSiteRootDir嗅探的网站根。 - 原因/取舍:SFTP 场景
findSiteRootDir返回临时目录根,实际网站根在子目录;用 baseDir 与transformHtmlResourcePaths解析一致避免层级错位。 - 状态:✅ 已落地(
asset_handler.go/sftp/service.go)
CSS MIME 按扩展名显式设置
- 决策:文件服务器按扩展名设 Content-Type(CSS →
text/css),不依赖http.DetectContentType。 - 原因/取舍:
DetectContentType对 CSS 返回text/plain,浏览器 strict MIME checking 拒绝应用样式。 - 状态:✅ 已落地(
asset_handler.go)
资源探测用 try-download
- 决策:用 try-download 探测资源存在性,而非先
GetFileInfo/Exists。 - 原因/取舍:七牛
GetFileInfo可能走签名域名返回 EOF,接口无Exists;直接尝试下载更稳妥,失败即跳过。 - 状态:✅ 已落地(
ossdrvtryDownloadResource)
OSS HTML 预览路由:/proxy → /localfs
- 决策:OSS 的 HTML 预览从
/proxy/html-preview(远程代理路由)改为先下载到临时目录再用/localfs/html-preview。 - 原因/取舍:本地文件服务器只注册了
/localfs/html-preview,/proxy/仅在 agent server;OSS 路径不是本地 FS 路径,代理路由无法处理。 - 状态:✅ 已落地(
useFilePreview.tshtmlPreviewUrl)
仅 HTML 类型走整站流程
- 决策:仅 HTML 预览走独立整站下载流程,其他类型(图片/视频/音频)走
resolveWithTransport()单文件。 - 原因/取舍:其他类型已通过
downloadForPreview+buildLocalUrl走/localfs/;只有 HTML 需下载关联资源。 - 状态:✅ 已落地(
useFilePreview.ts)
HTML 资源正则收窄 + srcset 补全
- 决策:
htmlResRegex中data收窄为data-src|data-url;新增srcset正则独立提取;排除<a href>导航链接和非资源扩展名。 - 原因/取舍:旧
data=误匹配data-id等无关属性产生无效拉取;srcset 是响应式图片标准属性旧逻辑完全遗漏;<a href>导航链接不应下载。 - 状态:✅ 已落地(
common/utils.go、site_resource.go)
资源提取逻辑 DRY 统一
- 决策:OSS 和 SFTP 的
extractResources/shouldSkipResource重复代码统一到filesystem/site_resource.go。 - 原因/取舍:两 service 各复制一份相同逻辑违反 DRY;统一净减 114 行。
- 状态:✅ 已落地
七牛 OSS
域名分类:临时域名优先
- 决策:下载域名解析临时域名优先(按后缀
*.qiniudns.com/*.clouddn.com/*.qbox.me分类),自定义域名降级。 - 原因/取舍:用户桶绑定的自定义域名不可达导致 EOF;临时域名更稳定。后缀 + 前缀双重匹配防误判。
- 状态:✅ 已落地(
qiniu/client.goresolveDownloadDomain)
域名探测策略演进:TCP → HTTP HEAD → 移除
- 决策:域名可达性探测从 TCP 连接 → HTTP HEAD → 最终移除,改用签名 URL 直接下载。
- 原因/取舍:TCP 探测端口开放但 HTTP 返回 EOF 无法发现;HTTP HEAD 对 5xx 无法区分真假可用;签名 URL 可绕防盗链,探测逻辑不再需要。
- 状态:✅ 已落地(
qiniu/client.go,probeDomain 已删)
签名 URL 绕防盗链
- 决策:
Download内部改用签名 URL 而非裸 HTTP GET;签名格式从URL\ndeadline修正为URL?e=deadline。 - 原因/取舍:裸 GET 遇 CDN 防盗链(478)被拒;签名 URL 绕过防盗链;参照 kodo-browser 始终用签名 URL。
- 状态:✅ 已落地(
qiniu/client.goGetSignedURL/Download)
region 从域名提取
- 决策:从 API 返回的临时域名提取真实 region(
detectRegionFromDomains),不依赖用户配置的config.Region。 - 原因/取舍:用户填的 Region 可能错误(填 z0 实际 as0),构造的降级域名不存在(502);从
u-res-as0.qiniudns.com提取才准确。 - 状态:✅ 已落地
跨账号桶返回错误
- 决策:
resolveDownloadDomain在 API 返回空域名列表时直接返回错误,不伪造<bucket>-<region>.qiniudns.com。 - 原因/取舍:跨账号桶
GetBucketDomains返回空,伪造域名不存在致 403;代码无法修跨账号问题。 - 状态:✅ 已落地
阿里云不适用此逻辑
- 决策:阿里云 OSS 不加域名探测/降级/签名 URL 逻辑。
- 原因/取舍:阿里云下载 URL 直接
<bucket>.<region>.aliyuncs.com,endpoint 固定官方域名始终可达;用户要求不影响阿里云正确访问。 - 状态:✅ 已落地
阿里云 OSS
手动签名不用官方 SDK
- 决策:采用手动签名,不引入阿里云官方 SDK。
- 原因/取舍:项目已有手动签名实现,避免重量级 SDK 依赖;需自行处理 base64(StdEncoding→URLEncoding)、URL 编码(PathEscape + QueryEscape)。
- 状态:✅ 已落地(
oss/aliyun/client.go)
endpoint 信任用户配置
- 决策:endpoint 直接传用户配置值,信任用户配置,不自动清空/覆盖。
- 原因/取舍:旧代码 region 非空时清空 endpoint 让 NewClient 从 region 派生,破坏 VPC/自定义域名场景;改为信任配置,NewClient 在 endpoint 空时才从 region 派生(已有此逻辑)。
- 状态:✅ 已落地(
ossdrv/service.go)
region 全量缓存 + 探测
- 决策:
Connect()时立即缓存所有桶 region 到bucketRegions,未命中时主动ListBuckets探测。 - 原因/取舍:app 重启后直接跳上次浏览的桶时缓存无数据,客户端用默认 hangzhou 创建致跨 region 访问失败。
- 状态:✅ 已落地(
oss/aliyun/)
缓存 key:GetFileInfo 失败直接报错
- 决策:
DownloadToTemp中 GetFileInfo 失败时直接返回错误,不静默降级用空 modTime。 - 原因/取舍:原失败时用空 modTime 继续生成 key,失败/成功状态产生不同 key 永远命中不了缓存;确定报错让调用方明确处理。
- 状态:✅ 已落地(
ossdrv/service.goDownloadToTemp)
Last-Modified 解析填入 FileInfo
- 决策:解析阿里云 HEAD 响应的 Last-Modified 头填入 FileInfo(原
_ =丢弃)。 - 原因/取舍:丢弃致
LastModified永远零值,缓存 key 用零值格式化后不稳定(失败/成功 key 不同),永远命中不了已有缓存。 - 状态:✅ 已落地(
oss/aliyun/client.goGetFileInfo)
缓存管理
预览缓存:前端 LRU Map → 后端 SQLite 24h
- 决策:预览缓存从前端 LRU Map(上限 50)迁移到后端 SQLite,键 (transport, connID, remotePath, fileSize, modTime),24h TTL。
- 原因/取舍:前端 JS Map 重启即丢失且无过期;后端 SQLite 重启后仍有效,size+modTime 进键源文件变动自动失效。
- 状态:✅ 已落地(
storage/download_cache.go)
站点缓存清理时机:启动 + 关闭
- 决策:站点预览缓存在启动和关闭时各清理一次 24h 过期目录,不引入后台定时器。
- 原因/取舍:站点缓存用户驱动低频,不需常驻 goroutine 轮询;启停各清一次覆盖典型生命周期。同时清遗留旧随机目录
udesk-site-*。 - 状态:✅ 已落地(
download_cache.goCleanupSiteCacheDirs +app.go)
内容类型检测缓存 200 条满清空
- 决策:
contentDetectCache加 200 条上限,满时整体clear()(非逐条 LRU)。 - 原因/取舍:无上限会内存泄漏;选「满即清空」非真 LRU——文件类型检测重复命中率低、清空成本低、实现简单。
- 状态:✅ 已落地(
useFilePreview.ts)
面包屑目录缓存 200 条
- 决策:面包屑悬停子目录列表用
Map<path, result>缓存,限 200 条。 - 原因/取舍:悬停需频繁查询,缓存避免重复网络请求;只缓存目录(悬停只展示可导航子目录)。
- 状态:✅ 已落地(
PathBreadcrumb.vuedirCache)
CodeEditor 滚动位置缓存
- 决策:滚动位置 LRU 最多 5 份,每份 3min 过期。
- 原因/取舍:文件切换保留编辑位置提升体验;5 份对应最近 5 文件,3min 过期避免长期占内存。
- 状态:✅ 已落地(
CodeEditor.vue)
错误处理策略
GetFileInfo 错误:吞掉 → slog.Warn
- 决策:Go 后端 6 处
infoMap, _ := GetFileInfo(...)改为slog.Warn可追踪。 - 原因/取舍:吞错误致前端收到零值数据(name/path/size 全零)既不可用又不可调试。
- 状态:✅ 已落地(
sftp/ossdrv)
前端 useFileOperations 去 catch 纯代理
- 决策:
useFileOperations删除全部 try-catch 改为纯代理。 - 原因/取舍:原 catch → onError → throw 三重处理致同一错误弹两次消息;改为边界层统一处理,中间层不吞异常。
- 状态:✅ 已落地(
useFileOperations.ts)
系统 API 边界层合理兜底(回滚)
- 决策:
system.ts4 个系统信息 API(CPU/内存/磁盘/环境变量)先删空 catch 后又加回轻量try { ... } catch { return {} }。 - 原因/取舍:先按确定性编程删空 catch,但审查发现系统信息属边界展示层——获取失败不应崩 UI,返回
{}是合理降级。与中间层「不吞异常」原则不同,这是边界层合理兜底。 - 状态:✅ 已落地(
api/system.ts)
目录加载失败提示
- 决策:
loadDirectory(dir).catch(() => {})静默吞改为Message.error提示。 - 原因/取舍:fire-and-forget 静默吞错时加载失败用户不知列表为何没更新,无从排障;用户感知优先。
- 状态:✅ 已落地(
index.vue)
系统信息部分成功降级
- 决策:
GetLocalSystemInfo三项采集全部失败才返回 error,部分成功仍返回已得数据。 - 原因/取舍:单项失败不应让整个接口不可用(降级优于全失败);全失败才说明采集机制本身有问题。
- 状态:✅ 已落地(
app_system.go)
OSS 重命名单文件失败不中断
- 决策:批量重命名中单文件删除失败不中断整批,记录并返回最后一个错误。
- 原因/取舍:单文件失败中断整批时已删无法回滚、未删无法继续体验更差;ossdrv 未引 slog 故用
fmt.Errorf累积。 - 状态:✅ 已落地(
ossdrv/service.go)
连接管理
_activeId 提前设置 + 失败回退
- 决策:
_activeId在buildAndPool()之前设置,失败时回退旧值。 - 原因/取舍:原在 buildAndPool 之后设,
setState('connected')回调读到旧 activeProfile 致 transport 类型判断错误。提前设置引入新问题:失败时卡在失败 ID 上 Sidebar 无法重试 → 保存旧值失败恢复。 - 状态:✅ 已落地(
connection-manager.tsconnect)
_lastActiveId 去重提前执行
- 决策:
_lastActiveId去重检查在_suppressAutoNav之前执行。 - 原因/取舍:原更新在 suppressAutoNav 检查之后,抑制时回调 return 致
_lastActiveId不更新;抑制后updateProfile异步 notifyChange 触发回调时_lastActiveId过期误触发导航到/覆盖正确路径。 - 状态:✅ 已落地(
index.vueonStateChange)
连接池复用前 isAlive 探测
- 决策:
connect()复用池中连接前先调transport.isAlive(),失效则清理重建。 - 原因/取舍:原见池中有就返回「已连接」,僵尸连接点击时直接返回已连接实际已断;复用前探测识别并移除失效连接。
- 状态:✅ 已落地(
connection-manager.ts)
isAlive 做成可选方法
- 决策:
FsTransport接口新增可选isAlive?(): Promise<boolean>,SFTP 用SftpGetFileInfo('/')、OSS 用OssListDir('')探测。 - 原因/取舍:用轻量只读操作作心跳而非重连,失败 catch 返回 false;可选方法(
?)保持对其他实现的向后兼容。 - 状态:✅ 已落地(
transport.ts+ 各 transport 实现)
远程 rootPath 始终返回 /
- 决策:远程连接的
rootPath始终返回/,忽略 localStorage 残留的 Windows 路径。 - 原因/取舍:原对远程用 filePath 前 4 段算 rootPath 过滤非目录后为空,悬停子目录列表空;远程根目录就是
/。远程模式若沿用本地缓存路径致导航失败。 - 状态:✅ 已落地(
ConnectionIndicator.vue)
连接失败中止打开文件
- 决策:
handleOpenFavorite连接失败时return中止,不用错误 transport 继续打开。 - 原因/取舍:原失败后继续导航用错误 transport(local)加载远程路径,OSS 文件被当本地处理(路径变
C:\Users\...)。 - 状态:✅ 已落地(
index.vuehandleOpenFavorite)
loadFileContent await updatePreviewUrl
- 决策:
loadFileContent中updatePreviewUrl改为await。 - 原因/取舍:OSS/SFTP 预览 URL 依赖异步下载到临时文件,不等待会竞态;本地文本不必要但无害,统一 await 简化逻辑。
- 状态:✅ 已落地(
index.vue)
字段映射 snakeToCamel
- 决策:前端
loadFromDB()用snakeToCamel()转换 Go 返回的 snake_case JSON。 - 原因/取舍:Go JSON 用 snake_case(
access_key),前端 TS 期望 camelCase(accessKey);直接...p展开不转换致profile.secretKey为 undefined,OSS 认证失败——收藏图片打不开的根因。 - 状态:✅ 已落地(
connection-manager.ts)
LoadConnectionProfiles 用 json.Marshal
- 决策:
LoadConnectionProfiles用json.Marshal/Unmarshal替代手动map[string]interface{}字段转换。 - 原因/取舍:手动 map 每字段
float64()断言,新增字段必漏;JSON 自动处理类型转换,Go struct 到前端 camelCase 全链路映射更可靠。 - 状态:✅ 已落地(
app.go)
watchFile 仅本地生效
- 决策:
watchFile/unwatchFile仅本地模式生效,远程连接静默跳过。 - 原因/取舍:远程 FS 无 inotify/ReadDirectoryChangesW 机制,强行监听增加复杂度和网络开销。
- 状态:✅ 已落地(
app.go+index.vue)
onStateChange 职责拆分(未实施)
- 决策:
onStateChange回调应拆分为「只管连接状态通知,导航权归 UI 层」。 - 原因/取舍:当前回调同时承担状态跟踪 + 自动导航,
_suppressAutoNav全局标志脆弱,任何异步竞争都绕过它(updateProfile的 notifyChange 异步触发是典型)。长期方案:连接管理器不管导航、导航权在 UI 显式调用、updateProfile 不触发 notifyChange。当前用_lastActiveId去重 + 提前更新作临时修复。 - 状态:📐 设计未实施(临时修复已落地)
SFTP
SSH keepalive 30s
- 决策:为每个 SSH 连接起独立 goroutine,每 30s 发送
keepalive@openssh.com。 - 原因/取舍:原无 keepalive,长时间空闲后 NAT/防火墙丢连接 SFTP 变「僵尸」;30s 防连接被杀。
- 状态:✅ 已落地(
sftp/client.go)
临时文件去掉 CreateTemp
- 决策:去掉
CreateTemp+ 二次Create,直接拼路径。 - 原因/取舍:原创建两次临时文件(先 CreateTemp 再 Create),多余 IO 增加失败点;直接拼路径减少一次系统调用与竞态窗口。
- 状态:✅ 已落地(
sftp/service.go)
parseProcMeminfo 统一单位
- 决策:
parseProcMeminfo统一先按 kB 运算、最后转 bytes。 - 原因/取舍:原 kB/bytes 混用易中间步骤算错;统一单位运算只在出口转换减少混淆。
- 状态:✅ 已落地(
sftp/service.go)
文件预览 / 编辑
文件读写大小上限
- 决策:本地读写上限 10MB,SFTP 对齐 10MB;预览模式放宽 50MB。
- 原因/取舍:编辑模式整文件加载到内存,10MB 平衡可用性和内存安全;预览可流式/只读,50MB 覆盖绝大多数视频/图片/音频。SFTP 与本地一致避免行为不一致。
- 状态:✅ 已落地(
filesystem/service.go、sftp/service.go)
大文件预检 BIG_FILE
- 决策:预览文件超
BIG_FILE(1MB)阈值时不读取内容,显示提示 return。 - 原因/取舍:基于目录列表 size 预检,避免读大文件致编辑器/前端卡死;阈值常量化便于统一调整。
- 状态:✅ 已落地(
FileEditorPanel.vue、constants.ts)
clearContent 重置 isBinaryFile
- 决策:
clearContent()必须重置isBinaryFile = false(之前只清 fileContent/originalContent)。 - 原因/取舍:模板
v-if链以isBinaryFile为第一分支;上一个是二进制、切换到图片/视频时 clearContent 不清该标志会永久命中二进制分支、阻断所有图片/视频预览(cascade 失效根因)。 - 状态:✅ 已落地(
useFileEdit.ts)
二进制/媒体用 isMediaPreviewable 判定
- 决策:用
isMediaPreviewable(filename)显式判断,替代originalContent.value === undefined。 - 原因/取舍:
originalContent === undefined在首次加载未完成时也 undefined,靠它区分「非文本」语义模糊不可靠。 - 状态:✅ 已落地(
index.vue)
非文本文件变更 _t 强制刷新
- 决策:非文本文件(图片/视频)变更后用 URL 追加
_t=timestamp强制刷新。 - 原因/取舍:方案简洁不侵入文件服务器 URL 生成;只在真正变化时追加,首次走浏览器缓存、tab 切换从缓存恢复原 URL 不产生额外请求。
- 状态:✅ 已落地(
index.vuehandleFileChanged)
_t 用真实 mtime 而非 Date.now
- 决策:
_t参数从Date.now()改为文件实际 mtime(os.StatModTime)。 - 原因/取舍:同文件多次保存同内容时
Date.now()让 mtime 不变但时间戳变;真实 mtime 正确反映修改时刻,且一次 os.Stat 同时完成存在性判断 + 取 mtime。事件 payload 从 string 演进为{path, mtime}map,前端兼容新旧格式。 - 状态:✅ 已落地(
filewatch/watcher.go+index.vue)
草稿恢复脏标记(取舍未决)
- 决策:加载草稿后不同步 originalContent,致文件打开即显示「脏」标记。
- 原因/取舍:草稿残留(localStorage 24h)在 loadFile 后覆盖 fileContent,originalContent 已设为原始内容,二者不同触发脏标记。设计意图恢复未保存编辑(预期),但「一打开就脏」让人困惑。取舍:保草稿恢复 vs 视觉干净——暂未改。
- 状态:❓ 未决
单文件 Tab 显示 + 空状态
- 决策:预览 Tab 栏显示条件从
length > 1改为> 0(单文件也显示可关闭);无预览显示 logo + 「U-Desk / 文件管理器」空状态。 - 原因/取舍:支持「关闭所有预览回到空状态」;空状态低透明度居中不抢焦点。
- 状态:✅ 已落地(
FileEditorPanel.vue)
AsyncCodeEditor 重试上限 2 次
- 决策:
defineAsyncComponent的 onError 限重试 2 次。 - 原因/取舍:Vue3 加载/渲染失败后进入永久死亡状态不自动重试;无限
retry()在网络持续故障时无限重试。策略演进:无限重试 → 上限 2 次。 - 状态:✅ 已落地(
FileEditorPanel.vue)
BGM 播放失败跳过不删
- 决策:播放失败自动跳下一首,不删除曲目。
- 原因/取舍:网络文件可能临时不可用(SFTP 断连、OSS 签名过期),删曲目会永久丢失播放列表;跳过保留重试机会。
- 状态:✅ 已落地(
BgmBar.vue)
文件监听
300ms 防抖
- 决策:fsnotify 文件变化事件后端 300ms 防抖后再
EmitEvent("file-changed")。 - 原因/取舍:编辑器连续保存(VS Code)短时间多次触发 fsnotify;防抖合并多次事件为一次避免前端反复刷新。
- 状态:✅ 已落地(
filewatch/watcher.go)
仅监听当前预览单文件 + 脏数据保护
- 决策:仅监听当前正在预览的单个文件;用户有未保存修改时跳过自动刷新。
- 原因/取舍:用户选「自动刷新 + 通知」+「仅当前预览文件」(拒绝「同时监听目录」开销大);跳过脏数据刷新防丢失未保存编辑。
- 状态:✅ 已落地(
index.vuehandleFileChanged)
防抖回调捕获局部 emit 变量
- 决策:防抖回调里把
w.emitEvent先捕获到局部变量emit再调用。 - 原因/取舍:
debounceTimer回调在独立 goroutine 执行,直接访问w.emitEvent不持锁,与Close()并发会 nil pointer panic;捕获局部变量即无需持锁。 - 状态:✅ 已落地(
watcher.go)
监听目录而非单文件
- 决策:监听文件所在目录而非文件本身。
- 原因/取舍:部分系统(某些 Linux)不支持直接监听单文件;监听目录兼容性更好,配合防抖过滤目标文件。
- 状态:✅ 已落地(
watcher.go)
自动更新
os.Exit → Quit 释放 mutex
- 决策:
os.Exit(0)改为application.Get().Quit()优雅退出。 - 原因/取舍:Wails v3 启用 SingleInstance 单实例锁;旧流程 bat 先 start 新进程再 os.Exit,新实例启动时旧进程未完全退出,SingleInstance 检测冲突拒绝。Quit 释放 mutex,bat 轮询等旧 PID 退出后再启新实例。
- 状态:✅ 已落地(
update.gorestartApplication +main.go)
.new 延迟替换
- 决策:运行中 exe 用
.new延迟替换(安装阶段只复制到 .new,重启阶段 bat 替换)。 - 原因/取舍:Windows 不允许覆盖正在运行的 exe;旧
os.Rename失败后静默return nil实际没替换。改为安装复制 .new、重启 bat 先杀进程再 move、启动ReplacePendingFile()兜底。 - 状态:✅ 已落地(
update.go)
bat 启动方式
- 决策:bat 用
exec.Command("cmd", "/C", batFile)+SysProcAttr{HideWindow: true},不经cmd /C start。 - 原因/取舍:
-H windowsgui模式下cmd /C start启动的 bat 可能被 Windows 安全策略拦截;直接 exec.Command + CREATE_NO_WINDOW 避免弹窗且不触发拦截。 - 状态:✅ 已落地(
update.go)
bat 写失败降级重启
- 决策:bat 写入失败时降级为
os.Exit(0)+ 直接exec.Command("cmd", "/C", "start", "", execPath).Start()。 - 原因/取舍:bat 依赖临时目录可写,权限不足/磁盘满则无法完成标准流程;降级牺牲 .new 替换但保证能退出并尝试重启。
- 状态:✅ 已落地(
update.gofallbackRestart)
check_url 空值才用默认
- 决策:
check_url仅在为空时用默认值,不覆盖已存在的旧 URL。 - 原因/取舍:尊重用户已配置值 vs 自动修正旧失效 URL(如
img.1216.top)——可能导致旧用户无法检测更新。迁移逻辑暂未加。 - 状态:🚧 待实测(当前配置已修,迁移逻辑未加)
更新检查 URL 配置化
- 决策:检查更新 URL 从硬编码改为读
update_config.json,空值 fallback 默认。 - 原因/取舍:硬编码无法按部署环境切换;完全无默认值会致配置缺失时检查失效,故保留 fallback。
- 状态:✅ 已落地(
app.go、update_config.go)
代码结构 / DRY
三套 FileItem 类型统一
- 决策:三套 FileItem 定义统一为
types/file-system.ts的一个,删除api/types.ts的Fileinterface。 - 原因/取舍:原 transport.ts 用
is_dir/mod_time(蛇形)、file-system.ts 用isDir/modified_time(驼峰)、api/types.ts 用isDir/modified(又不同),加两套 transformFile 易错;统一为驼峰,transport 层做一次蛇→驼峰转换。 - 状态:✅ 已落地(
types/file-system.ts、api/transport.ts、api/types.ts)
CopyFile 失败清理 + 权限保留
- 决策:
common.CopyFile加失败清理(os.Remove dst)+ 保留源文件权限(os.Chmod)。 - 原因/取舍:从 download_cache/recycle_bin/update 三处提取共享 CopyFile 时,原各处有失败清理和权限保留,合并不能丢;recycle_bin 依赖文件权限不变。
- 状态:✅ 已落地(
common/utils.go)
全局单例死代码删除
- 决策:删除
globalLogger/globalRecycleBin/LogOperation/LogError/GetGlobalLogger/GetStackTrace等全局单例,而非加 sync.Once 保护。 - 原因/取舍:这些只有声明和 getter 从未初始化或调用,属死代码;直接删比加保护更干净。
- 状态:✅ 已落地(
logger.go、recycle_bin.go)
App 拆分粒度:11 文件不再动
- 决策:11 个
app_*.go按功能域拆分,不再进一步合并或细分。 - 原因/取舍:命名清晰符合 Go 多文件包惯例;继续拆分或合并都增加认知负担,「保持现状」是当前最佳点。
- 状态:✅ 已落地(决定不动)
Windows 专用代码 build tag 隔离
- 决策:
registry/w32等 Windows 专用包函数移到app_windows.go,跨平台函数留app.go。 - 原因/取舍:Windows 专用包直接 import 致跨平台编译失败;build tag 文件拆分而非运行时 GOOS 判断,是 Go 惯例且零运行时开销。
- 状态:✅ 已落地(
app.go/app_windows.go)
watchFile 接口统一
- 决策:
watchFile/unwatchFile统一走FsTransport接口。 - 原因/取舍:原各 transport 各自实现接口不统一;收敛后共用同一套签名和调用模式。
- 状态:✅ 已落地(
transport.ts+ 4 个 transport 实现)
旧版遗留清理
- 决策:删除 3 个旧版零引用 composable JS 文件(~1000 行)+ Wails v2 遗留 wailsjs 绑定。
- 原因/取舍:旧版 JS/wailsjs 文件无 import 引用属历史遗留,留着混淆开发。
- 状态:✅ 已落地(
composables/*.js、wailsjs/wailsjs/)
窗口外观
DWMWA_COLOR_NONE 抑制白边
- 决策:用
DWMWA_COLOR_NONE(0xFFFFFFFE)作四主题状态 BorderColour,移除DisableFramelessWindowDecorations。 - 原因/取舍:白边根因是
ExtendFrameIntoClientArea保留 DWM 框架、暗色模式未告知 DWM 用暗色渲染;试过#2D2D2D、移除 CustomTheme、DisableFramelessWindowDecorations:true均无效;DWMWA_COLOR_NONE是 Win11 官方抑制边框 API。Win10 不支持降级无装饰(取舍:牺牲 Win10 Aero 阴影/圆角换 Win11 干净边缘)。 - 状态:✅ 已落地(
main.go)
背景色固定 #2D2D2D
- 决策:窗口
BackgroundColour固定#2D2D2D(暗色安全)。 - 原因/取舍:此色仅 CSS 未渲染前的加载瞬间可见;暗色模式不露白边,亮色模式被前端 CSS 覆盖;不随主题切换避免 Go 端复杂度。
- 状态:✅ 已落地(
main.go)
UI 交互
设置面板 Teleport to body
- 决策:弹出面板从 sidebar 内
position: absolute改为<Teleport to="body">+position: fixed。 - 原因/取舍:旧方案被
overflow: hidden容器裁切,设置按钮/更多菜单弹出被截断;Teleport 彻底解决 z-index 和 overflow:hidden 裁剪。 - 状态:✅ 已落地(
Sidebar.vue)
服务器区块 max-height: none
- 决策:服务器区块
max-height: none不限高;收藏夹区块保留max-height折叠动画。 - 原因/取舍:
max-height: 500px在服务器列表 >15 行时裁剪;服务器区块在flex-shrink: 0内高度由内容决定即可。折叠动画只适合可变高度区块。 - 状态:✅ 已落地(
Sidebar.vue)
收藏夹默认折叠
- 决策:收藏夹侧栏
favCollapsed初始值改true(默认收起)。 - 原因/取舍:保持界面简洁,用户按需展开。
- 状态:✅ 已落地
折叠动画 max-height:0
- 决策:折叠动画从
grid-template-rows: 0fr改为max-height: 0+overflow: hidden。 - 原因/取舍:
0fr方案折叠后仍有残留高度;帮助区块不再margin-top: auto,依赖收藏区块flex-grow自然推到底。 - 状态:✅ 已落地(
Sidebar.vue)
toggleSettings 用 closest
- 决策:
toggleSettings用closest('.settings-btn')替代e.currentTarget。 - 原因/取舍:Vue 3 内联事件
e.currentTarget有 null 风险(Vue 内部包装),原|| e.target兜底有类型安全隐患(e.target 可能是子文本节点);closest 向上查找更可靠。 - 状态:✅ 已落地(
Sidebar.vue)
面包屑下拉过滤 + 200 + 缓存
- 决策:面包屑下拉只获取目录(跳过文件),
slice(0, 200)限制,Map<path, result>跨导航缓存。 - 原因/取舍:
/tmp等大目录返回过多卡顿;用户只能导航到目录,过滤文件减 payload;200 上限防极端,Map 缓存避免悬停同路径重复请求。 - 状态:✅ 已落地(
PathBreadcrumb.vue)
拖拽 iframe 遮罩
- 决策:resize 拖拽时
mousedown即激活全屏遮罩,并缓存容器尺寸。 - 原因/取舍:iframe(PDF/图片预览)捕获鼠标事件致拖拽中断;每帧
getBoundingClientRect()强制回流;遮罩从 rAF 回调改 mousedown 立即触发,避免鼠标未移动时遮罩不出现。 - 状态:✅ 已落地(
resize.ts)
系统信息 15s 自动刷新
- 决策:系统信息面板
setInterval每 15s 刷新。 - 原因/取舍:固定周期足够反映服务器状态变化且不致过大开销。
- 状态:✅ 已落地(连接管理器 refreshTimer)
F11 预览全屏:OS 级 → 窗口内全屏 [2026-06-13]
- 决策:文件预览区 F11 全屏从浏览器 Fullscreen API(OS 级,占整个屏幕)改为 CSS class 驱动的窗口内全屏(
position: fixed; inset: 0),只盖 u-desk 应用窗口视口。 - 原因/取舍:OS 全屏独占整个显示器、遮任务栏、退出后窗口尺寸/位置可能错乱,对桌面文件管理器过重;窗口内全屏只占应用视口,保留任务栏与多窗口切换,符合「应用内聚焦预览」预期。代价:失去浏览器原生 ESC 退出 → 自行补 ESC 监听退出 + F11 再按切换。实现:
isFullscreenref 驱动.is-fullscreenclass,移除requestFullscreen/exitFullscreen/fullscreenchange监听器及panelRef,:fullscreen选择器全部改.is-fullscreen。 - 状态:✅ 已落地(
FileEditorPanel.vue,wails3 build 通过 2026-06-13)
快捷键 / 热键
RegisterGlobalHotkey 单 defer Unlock
- 决策:合并为单一
defer mu.Unlock(),消除锁间隙竞态。 - 原因/取舍:原 nil 检查和赋值不在同一把锁内(Lock→检查→Unlock→注册→Lock→赋值→Unlock),两 goroutine 可同时通过 nil 检查致重复注册。
- 状态:✅ 已落地(
app.goRegisterGlobalHotkey)
盘符快捷键先回本地
- 决策:
Ctrl+Shift+C~H盘符快捷键切换前先回本地 transport。 - 原因/取舍:远程模式下按盘符快捷键会报错(C:~H: 盘符操作不适用远程 transport);先切回本地再执行盘符切换。
- 状态:✅ 已落地(
index.vue)
DevTools
build tag 控制
- 决策:用 Go build tag(
!production/production)控制 DevTools 开关。 - 原因/取舍:
wails dev和wails3 build默认都带 devtools tag 致生产包弹 Inspector;曾考虑运行时Env.Info().Debug判断但 Run() 前不可用;build tag 编译期控制零运行时开销更可靠。 - 状态:✅ 已落地(
devtools.go/devtools_prod.go)
桌面应用体验
UniqueID 反向域名
- 决策:UniqueID 用
top.1216.udesk,不用com.1216.top.udesk。 - 原因/取舍:用户域名
1216.top,按反向域名规范应为top.1216.udesk;com.开头系统层面也能工作但用户明确要求严格反向域名规范。 - 状态:✅ 已落地(
main.goSingleInstance)
全局禁用浏览器右键菜单
- 决策:
App.vueonMounted 添加contextmenu事件拦截,全局禁用浏览器默认右键菜单。 - 原因/取舍:桌面应用露浏览器默认右键菜单破坏原生体验;CSS
--default-contextmenu: hide在 WebView2 不一定生效,JSpreventDefault更可靠。 - 状态:✅ 已落地(
App.vue)
版本 / 依赖
Wails 暂缓升级
- 决策:暂不升级(停留 alpha.80,最新 alpha.86)。
- 原因/取舍:升级主要带来 Windows 托盘 GDI 内存泄漏修复、WebView2 焦点空指针修复,但对本项目影响不大;而改进清单依赖的原生对话框/窗口事件 API 新版仍未提供,升级收益不足以抵消回归风险。
- 状态:🚧 待实测(暂缓非永久否决)
文档 / 流程
CHANGELOG 用户视角
- 决策:CHANGELOG.md 只写用户能感知的变化,不写内部实现细节。
- 原因/取舍:CHANGELOG 面向用户非开发者;内部重构(结构化日志、并发安全、依赖升级)只作概括条目,详细技术改动放 CHANGELOG.internal.md;新功能不应归入「修复」。
- 状态:✅ 已落地
调试工具验证后统一清理
- 决策:调试日志和临时测试工具在功能验证通过后再统一清理,不随修复合并。
- 原因/取舍:OSS 连接问题未完全验证前用户要求「先不清理」;调试日志帮助定位了
updateProfile异步 notifyChange 误触发导航的 bug。最终验证后统一清理 cmd 调试工具、DebugLog 绑定、前端调试日志。 - 状态:✅ 已落地
已知上游依赖问题
Chromium 149 中文 IME 标点按两次丢失 [2026-06-13]
- 决策:确认是 Chromium 149 上游 bug,不在应用层修;等 Chrome/Edge 151 推送更新 WebView2 Runtime 解,或临时切 IME 英文标点绕过。
- 原因/取舍:症状——中文输入法下编辑器(CodeMirror)直接打全角标点(
。,;:)每隔一次丢失,需按两次。排查已排除:u-desk 全局快捷键(index.vuehandleKeyDown /FileEditorPanelonKeyDown 全部需 Ctrl/Meta/Alt/Shift 或功能键,裸标点不命中不拦截)、CodeMirror 配置(6.41.1,无 closeBrigits/无标点 keymap 绑定)、watch modelValue回写(字符串相等不重派发)。CodeMirror 作者 marijn 用裸<div contenteditable>也在 Chrome 149 stable 复现,提 Chromium #521205128 → 纯内核 bug,非本项目代码。应用层无法修:丢失的那次按键在 Chromium 内部被吞,不触发任何 JS 事件(无 keydown/beforeinput/compositionend),拦不到。不在 CodeMirror 加 inputHandler/keydown hack:作者本人只提 issue 未给 workaround,硬 hack 引新问题。当前环境:WebView2 Runtime = Edge = 149.0.4022.62(Chromium 149,2026-06-02 stable),通道覆盖未设(stable 正常)。根因(2026-06-12 上游确认):Chromium 的 TSFTextStore 在 IME 活动时仍跑 autocorrect 检测,吞掉标点;修复 CL 7917332(Pranav Modi/MS)标题 "Skip autocorrect detection when IME is active in TSFTextStore"。 - 状态:🚧 上游已修复待发布(2026-06-12 在 Chrome 151.0.7888.0 / Win11 验证有效,已加 verified 标签;接受现状不应用层修)
- 📋 待办:修复落 Chrome 151(非 150)。等 Edge/WebView2 Runtime 升到 151 stable(Chrome 150 stable ~2026-06-29,151 stable 约 2026-07 下旬;急用可装 Edge Beta 151 提前验)→ 更新后回归验证编辑器中文标点。参考 CodeMirror 论坛 #9741。
维护说明
本文档为初始整理,覆盖 7 个历史会话 + 当前会话,共 ~90 条去重决策。
压缩信号:当前 ~19 功能域、超 300 行。后续触发以下任一条件时压缩:
- 老 Sprint 决策超 3 个 → 归档到
功能决策记录-归档.md - 某功能域 > 10 条 → 单独拆文件
- 已稳定决策合并精简,只留结论 + 关键原因
更新优先于新增:决策点已存在就改原条,演进用 → 串联,不留重复版本。