diff --git a/app_window_fix.go b/app_window_fix.go new file mode 100644 index 0000000..56cfa92 --- /dev/null +++ b/app_window_fix.go @@ -0,0 +1,133 @@ +//go:build windows + +package main + +import ( + "strings" + "sync/atomic" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Chrome_WidgetWin 系列类名是 WebView2 的宿主窗口层级 +const webviewHostClassFragment = "Chrome_WidgetWin" + +// wmSize Win32 窗口尺寸变化消息 +const wmSizeMsg uint32 = 0x0005 + +var ( + // user32 对这三个 API 只导出无后缀名(无 A/W 变体,带 W 后缀找不到 proc 会 panic) + procGetClientRectFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetClientRect") + procSetWindowPosFix = windows.NewLazySystemDLL("user32.dll").NewProc("SetWindowPos") + procGetAncestorFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetAncestor") +) + +// 修复去重:同一时刻只允许一个校验在跑(回调写共享切片要求互斥) +var webviewFixRunning atomic.Bool + +// 当前校验的目标主窗口(枚举回调过滤直属子窗口用) +var webviewTargetHwnd uintptr + +// webviewHosts 收集结果容器;enumChildWebviewHosts 回调写入 +var webviewHosts []windows.HWND + +// 包级变量:syscall.NewCallback 指向的 Go 闭包必须存活于整个进程生命周期 +var enumChildWebviewHosts = syscall.NewCallback(func(h uintptr, _ uintptr) uintptr { + // GA_PARENT(1):只收直属子窗口,避免动到更深层的 WebView2 内部窗口 + parent, _, _ := procGetAncestorFix.Call(h, 1) + if parent != webviewTargetHwnd { + return 1 + } + var buf [256]uint16 + if _, err := windows.GetClassName(windows.HWND(h), &buf[0], int32(len(buf))); err != nil { + return 1 + } + if strings.Contains(syscall.UTF16ToString(buf[:]), webviewHostClassFragment) { + webviewHosts = append(webviewHosts, windows.HWND(h)) + } + return 1 +}) + +// checkAndFixFill 校验 WebView2 宿主是否铺满窗口客户区,未铺满则强制铺满。 +// wails alpha 的 WM_SIZE 处理存在竞态:主线程忙时消息被 resizePending 标志丢弃, +// 迟到执行的重铺又使用闭包捕获的过期 bounds,webview 会停在中间尺寸且不再有 +// 新消息来纠正,表现为整个应用内容收窄、四周露出窗口底色。 +func checkAndFixFill(hwnd uintptr) { + if hwnd == 0 { + return + } + var rc struct{ Left, Top, Right, Bottom int32 } + if r, _, _ := procGetClientRectFix.Call(hwnd, uintptr(unsafe.Pointer(&rc))); r == 0 { + return + } + cw, ch := int(rc.Right-rc.Left), int(rc.Bottom-rc.Top) + if cw <= 0 || ch <= 0 { + return + } + + webviewTargetHwnd = hwnd + webviewHosts = webviewHosts[:0] + windows.EnumChildWindows(windows.HWND(hwnd), enumChildWebviewHosts, nil) + for _, h := range webviewHosts { + // SWP_NOZORDER(0x0004):只改尺寸位置,不干预 wails 管理的层级 + procSetWindowPosFix.Call(uintptr(h), 0, 0, 0, uintptr(cw), uintptr(ch), 0x0004) + } +} + +// runGuarded 统一 panic 兜底:windowsgui 下 goroutine panic 会静默杀进程 +func runGuarded(fn func()) { + defer func() { recover() }() + fn() +} + +// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次 +func fixWebviewFill(hwnd uintptr) { + if !webviewFixRunning.CompareAndSwap(false, true) { + return + } + go func() { + defer webviewFixRunning.Store(false) + runGuarded(func() { + time.Sleep(150 * time.Millisecond) + checkAndFixFill(hwnd) + }) + }() +} + +// StartWebviewFillWatch 周期兜底:覆盖"无新 WM_SIZE 的迟到重放"场景 +// (主线程忙时排队的过期重绘在窗口静止时才执行,此时不会产生任何 WM_SIZE) +func StartWebviewFillWatch() { + go runGuarded(func() { + for { + time.Sleep(3 * time.Second) + if webviewFixRunning.CompareAndSwap(false, true) { + runGuarded(func() { + checkAndFixFill(findMainWindowHwnd()) + }) + webviewFixRunning.Store(false) + } + } + }) +} + +// findMainWindowHwnd 找本进程的可见主窗口 +func findMainWindowHwnd() uintptr { + var found uintptr + pid := windows.GetCurrentProcessId() + cb := syscall.NewCallback(func(h uintptr, _ uintptr) uintptr { + var winPid uint32 + windows.GetWindowThreadProcessId(windows.HWND(h), &winPid) + if winPid == pid && windows.IsWindowVisible(windows.HWND(h)) { + found = h + return 0 + } + return 1 + }) + runGuarded(func() { + windows.EnumWindows(cb, nil) + }) + return found +} diff --git a/frontend/src/components/FileSystem/index.vue b/frontend/src/components/FileSystem/index.vue index 2bb9c59..b8fe180 100644 --- a/frontend/src/components/FileSystem/index.vue +++ b/frontend/src/components/FileSystem/index.vue @@ -513,8 +513,11 @@ const fileEditorPanelConfig = computed(() => { currentFileExtension, isBinaryFile: isBinaryFileRef.value, fileMtime: selectedFileItem.value?.modified_time || '', - // 多 tab - previewTabs: multiPreview.tabs.value, + // 多 tab(按当前服务器过滤,跨服务器的标签不显示在浮动标签栏) + previewTabs: (() => { + const pid = connectionManager.activeProfile?.id + return multiPreview.tabs.value.filter(t => (t.profileId ?? 'local-default') === pid) + })(), activeTabId: multiPreview.activeTabId.value, // 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null) activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null, @@ -569,6 +572,7 @@ const handleFileChanged = async (event: any) => { // 同路径也强制重载:navigate 同路径早退时列表会永远不加载 const switchConnection = async (profileId: string) => { await connectionManager.connect(profileId) + await syncPreviewToActiveProfile() await loadCommonPaths() const targetPath = connectionManager.isRemote() ? '/' : 'C:/' if (filePath.value === targetPath) { @@ -596,6 +600,7 @@ const handleGoToPath = async (path: string) => { const targetProfileId = getProfileIdForPath(path) if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) { await connectionManager.connect(targetProfileId) + await syncPreviewToActiveProfile() } await navigate(path) } @@ -1459,6 +1464,26 @@ const doCloseTab = (tabId: string) => { } } +/** 切换服务器后预览区联动:只显示当前服务器的标签与内容,避免旧服务器文档残留卡住 */ +const syncPreviewToActiveProfile = async () => { + const currentProfileId = connectionManager.activeProfile?.id + const isMine = (t: PreviewTab) => (t.profileId ?? 'local-default') === currentProfileId + + const active = multiPreview.activeTab.value + if (active && isMine(active)) return + + // 面板上残留的是旧服务器内容,先缓存回它自己的 tab(loaded 标记让切回时秒恢复) + cacheCurrentTabState() + const mine = multiPreview.tabs.value.find(isMine) + if (mine) { + await activateTab(mine) + } else { + // 当前服务器没有已打开的预览:回到空白预览态 + selectedFileItem.value = null + clearContent() + } +} + /** 缓存当前 tab 的编辑状态 */ const cacheCurrentTabState = () => { multiPreview.cacheCurrentTab({ diff --git a/main.go b/main.go index 9a2b3f5..472e8aa 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,9 @@ func main() { app.HandleHotkey() return 0, true } + case wmSizeMsg: + // webview 尺寸失同步兜底(app_window_fix.go) + go fixWebviewFill(hwnd) } return 0, false }, @@ -105,6 +108,9 @@ func main() { app.SetMainWindow(window) + // webview 与窗口尺寸失同步的周期兜底(app_window_fix.go) + StartWebviewFillWatch() + // 外部文件拖入窗口(OS 资源管理器拖放):把本地路径转发给前端处理 // (本机面板=复制进当前目录,SFTP 面板=上传) window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {