diff --git a/.agents/notes/implemented/architecture/2026-11-10-workbench-root-page-and-viva-upgrade.md b/.agents/notes/implemented/architecture/2026-11-10-workbench-root-page-and-viva-upgrade.md index 1b498b0ba..66028c9df 100644 --- a/.agents/notes/implemented/architecture/2026-11-10-workbench-root-page-and-viva-upgrade.md +++ b/.agents/notes/implemented/architecture/2026-11-10-workbench-root-page-and-viva-upgrade.md @@ -24,6 +24,17 @@ - **文件树。** 升级后用 `TreeState.RowID` 让选中跟随键盘焦点:方向键移动选中,点击行把焦点放到行,“定位当前文件”把焦点移到行。 - **窗口回调只剩三件上游没给的事**:`OnKey`(Splitter 的方向键、Search Everywhere 的方向键与 Enter)、`OnShortcut`(终端和编辑器的复制、剪切、粘贴、Ctrl 系列等“chord”,`Handlers` 只收键、不收 chord)、`OnScroll`/`OnPointer`(树的横向滚轮、标签栏滚轮、纵向滑动时去掉横向分量、指针离开窗口时关悬停)。它们都只做这一件事,不再有分发层。 +### 工作台背景的绘制顺序 + +项目渐变属于窗口边框、按钮栏和面板之间的空隙,工具面板和编辑器仍应是不透色的底色。 +根容器通过 `Draw` 在子元素之前绘制渐变。不要为了让渐变铺满窗口,把它放进 +绝对定位子元素:Viva 会在普通子元素后绘制绝对定位元素,即使它在声明中排在最前面, +也会让渐变盖住纯色面板。这是应用对绘制层次的使用约束,不需要修改 Viva 的绘制顺序。 + +`TestVisualWorkbenchGradientStaysBehindPanels` 使用 Metal 无头渲染检查面板空白区域的像素; +修复前会混入项目颜色,修复后必须保持 `#191a1c`。深浅色页面清单通过 +`TestVisualWorkbench` 与旧 macOS 采集包对照,截图只作外观证据,不替代原生交互验收。 + ## 考虑过的备选方案 - 先在 `339e133` 上做完根 Page 再升级 Viva:该版本没有 `DragState` 和 `RowID`,拖拽会先写一遍临时状态机再删,所以直接在新版本上做。 diff --git a/desktop/internal/i18n/i18n_test.go b/desktop/internal/i18n/i18n_test.go index f42224cad..4203b8116 100644 --- a/desktop/internal/i18n/i18n_test.go +++ b/desktop/internal/i18n/i18n_test.go @@ -64,7 +64,7 @@ func TestWelcomeLabelsUseSharedLanguage(t *testing.T) { empty string }{ {English, "Projects", "Open", "No recent projects"}, - {Chinese, "项目", "打开", "还没有最近项目"}, + {Chinese, "项目", "打开", "没有最近项目"}, } { welcome := For(test.language).Welcome if welcome.Projects != test.projects || welcome.Open != test.open || welcome.EmptyTitle != test.empty { diff --git a/desktop/internal/i18n/shared.generated.go b/desktop/internal/i18n/shared.generated.go index e28b761de..ee87c8bbe 100644 --- a/desktop/internal/i18n/shared.generated.go +++ b/desktop/internal/i18n/shared.generated.go @@ -332,6 +332,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.debug.watchPlaceholder": "Evaluate expression", "desktop.debug.watches": "Watches", "desktop.debug.writeTimedOut": "Writing to the debug adapter timed out.", + "desktop.diff.keepWhitespace": "Do not ignore whitespace", "desktop.diffFind.close": "Close find", "desktop.diffFind.field": "Find in diff", "desktop.diffFind.next": "Next match", @@ -1427,6 +1428,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.toolchains.unavailable": "Unavailable: {reason}", "desktop.tools.background": "Background", "desktop.tools.dragHint": "Drag to move elsewhere", + "desktop.tools.moveTo": "Move to", "desktop.tools.structure": "Structure", "desktop.updates.available": "Lithe {version} is available. Open Settings > Updates to install it.", "desktop.vcs.commitDirectly": "Commits are sent to the repository right away; there is no separate push.", @@ -1439,7 +1441,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.viewer.openFailed": "Could not open the file: {reason}", "desktop.viewer.truncated": "Only the first 64 KB are shown.", "desktop.welcome.dropFolderOnly": "Drop a folder to open it as a project.", - "desktop.welcome.emptyHint": "Open a folder to get started", + "desktop.welcome.emptyHint": "Open a local folder to start working.", "desktop.welcome.emptyTitle": "No recent projects", "desktop.welcome.menuPin": "Pin to Top", "desktop.welcome.menuReveal": "Show in Finder", @@ -1883,11 +1885,13 @@ var sharedCatalogs = map[string]map[string]string{ "native.clearThisProjectSLocalHistory": "Clear this project's local history?", "native.clearing": "Clearing…", "native.closeAllTabs": "Close All Tabs", + "native.closeDiff": "Close diff", "native.closeOtherTabs": "Close Other Tabs", "native.closeProject": "Close Project", "native.closeTabsToTheLeft": "Close Tabs to the Left", "native.closeTabsToTheRight": "Close Tabs to the Right", "native.closeUnmodifiedTabs": "Close Unmodified Tabs", + "native.closingATerminalTab": "Closing a terminal tab", "native.collapseDirectory": "Collapse directory", "native.collapseFilteredFrames": "Collapse filtered frames", "native.collapseSection": "Collapse section", @@ -1913,6 +1917,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.copyMethodName": "Copy Method Name", "native.copyName": "Copy Name", "native.copyPath": "Copy Path", + "native.copyPathReference": "Copy Path / Reference", "native.copyRelativeLocation": "Copy Relative Location", "native.copyRelativePath": "Copy Relative Path", "native.copySourceLocation": "Copy Source Location", @@ -1934,6 +1939,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.dependencies": "Dependencies", "native.detectAgain2": "Detect Again", "native.detectingWorkingCopies": "Detecting working copies…", + "native.diffSettings": "Diff settings", "native.disable": "Disable", "native.disabled.variant2": "Disabled", "native.discoverTests": "Discover Tests", @@ -2041,6 +2047,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.muteBreakpoints": "Mute breakpoints", "native.name": "Name", "native.needsNoExtraPermissions": "Needs no extra permissions.", + "native.new": "New", "native.newChangeList": "New ChangeList", "native.newTerminalWithShell": "New terminal with shell", "native.newWindow": "New Window", @@ -2265,6 +2272,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.view": "View", "native.viewBreakpoints": "View Breakpoints", "native.waitForTheRunningVersionControlActionToFinish": "Wait for the running version control action to finish.", + "native.whenQuittingLithe": "When quitting Lithe", "native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst": "When the limit is reached, the oldest versions are removed first.", "native.whereWouldYouLikeToOpenTheProject": "Where would you like to open the project ‘{arg1}’?", "native.wholeWords": "Whole Words", @@ -2678,6 +2686,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.debug.watchPlaceholder": "求值表达式", "desktop.debug.watches": "监视", "desktop.debug.writeTimedOut": "向调试适配器写入超时。", + "desktop.diff.keepWhitespace": "不忽略空白", "desktop.diffFind.close": "关闭查找", "desktop.diffFind.field": "在差异中查找", "desktop.diffFind.next": "下一个匹配", @@ -3773,6 +3782,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.toolchains.unavailable": "不可用:{reason}", "desktop.tools.background": "背景", "desktop.tools.dragHint": "可拖动到其他位置", + "desktop.tools.moveTo": "移动到", "desktop.tools.structure": "结构", "desktop.updates.available": "Lithe {version} 已发布,可在“设置 > 更新”中安装。", "desktop.vcs.commitDirectly": "提交会立即发送到仓库,没有单独的推送步骤。", @@ -3785,8 +3795,8 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.viewer.openFailed": "无法打开文件:{reason}", "desktop.viewer.truncated": "仅显示前 64 KB。", "desktop.welcome.dropFolderOnly": "请拖入文件夹以将其作为项目打开。", - "desktop.welcome.emptyHint": "打开一个文件夹开始工作", - "desktop.welcome.emptyTitle": "还没有最近项目", + "desktop.welcome.emptyHint": "打开本地文件夹开始工作。", + "desktop.welcome.emptyTitle": "没有最近项目", "desktop.welcome.menuPin": "固定到顶部", "desktop.welcome.menuReveal": "在访达中显示", "desktop.welcome.menuRevealOther": "在文件管理器中显示", @@ -4229,11 +4239,13 @@ var sharedCatalogs = map[string]map[string]string{ "native.clearThisProjectSLocalHistory": "清空此项目的本地历史?", "native.clearing": "正在清空…", "native.closeAllTabs": "关闭所有标签页", + "native.closeDiff": "关闭 Diff", "native.closeOtherTabs": "关闭其他标签页", "native.closeProject": "关闭项目", "native.closeTabsToTheLeft": "关闭左侧标签页", "native.closeTabsToTheRight": "关闭右侧标签页", "native.closeUnmodifiedTabs": "关闭未修改的标签页", + "native.closingATerminalTab": "关闭终端标签页", "native.collapseDirectory": "折叠目录", "native.collapseFilteredFrames": "折叠已过滤的栈帧", "native.collapseSection": "折叠分组", @@ -4259,6 +4271,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.copyMethodName": "复制方法名", "native.copyName": "复制名称", "native.copyPath": "复制路径", + "native.copyPathReference": "复制路径 / 引用", "native.copyRelativeLocation": "复制相对位置", "native.copyRelativePath": "复制相对路径", "native.copySourceLocation": "复制源码位置", @@ -4280,6 +4293,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.dependencies": "依赖", "native.detectAgain2": "重新识别", "native.detectingWorkingCopies": "正在识别工作副本…", + "native.diffSettings": "Diff 设置", "native.disable": "禁用", "native.disabled.variant2": "已禁用", "native.discoverTests": "发现测试", @@ -4387,6 +4401,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.muteBreakpoints": "暂停断点", "native.name": "名称", "native.needsNoExtraPermissions": "不需要额外权限。", + "native.new": "新增", "native.newChangeList": "新建更改列表", "native.newTerminalWithShell": "使用 Shell 新建终端", "native.newWindow": "新窗口", @@ -4611,6 +4626,7 @@ var sharedCatalogs = map[string]map[string]string{ "native.view": "查看", "native.viewBreakpoints": "查看断点", "native.waitForTheRunningVersionControlActionToFinish": "请等待正在执行的版本控制操作完成。", + "native.whenQuittingLithe": "退出 Lithe 时", "native.whenTheLimitIsReachedTheOldestVersionsAreRemovedFirst": "达到上限时,最早的版本会先被删除。", "native.whereWouldYouLikeToOpenTheProject": "你想在哪里打开项目“{arg1}”?", "native.wholeWords": "全字匹配", diff --git a/desktop/internal/logwindow/visual_capture_test.go b/desktop/internal/logwindow/visual_capture_test.go new file mode 100644 index 000000000..8cbeb1ff9 --- /dev/null +++ b/desktop/internal/logwindow/visual_capture_test.go @@ -0,0 +1,42 @@ +//go:build vivahead && lithevisual + +package logwindow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/vivaui/viva/headless" +) + +// TestVisualLogs renders synthetic records through the production diagnostics Page. +func TestVisualLogs(t *testing.T) { + out := os.Getenv("LITHE_VISUAL_OUTPUT_DIR") + if out == "" { + t.Skip("set LITHE_VISUAL_OUTPUT_DIR") + } + if err := os.MkdirAll(out, 0755); err != nil { + t.Fatal(err) + } + for _, dark := range []bool{true, false} { + t.Run(map[bool]string{true: "dark", false: "light"}[dark], func(t *testing.T) { + fixture := newExportFixture(t) + fixture.model.started = true + fixture.model.follows = false + ctrl := NewController(WindowOptions{Text: func() *i18n.Messages { return i18n.For(i18n.Chinese) }, Dark: func() bool { return dark }}) + w := newWindow(ctrl, fixture.model) + fixture.model.opts.Wake = w.inv.Invalidate + fillRecords(fixture.model, 40) + session, err := headless.Open(w.config(), nil, headless.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(session.Close) + if err := session.Screenshot(filepath.Join(out, "72-logs-"+map[bool]string{true: "dark", false: "light"}[dark]+".png")); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/desktop/internal/settingswindow/autosave_view.go b/desktop/internal/settingswindow/autosave_view.go index 05522ab66..7eecf7e70 100644 --- a/desktop/internal/settingswindow/autosave_view.go +++ b/desktop/internal/settingswindow/autosave_view.go @@ -5,7 +5,7 @@ import ( "github.com/vivaui/viva/ui" ) -// autoSaveCard declares the auto-save switch of the Editor page: changed files are saved a +// autoSaveCard declares the auto-save switch of the General page: changed files are saved a // moment after typing stops, on by default. The switch hands its change to the settings // owner at once. func (w *window) autoSaveCard(c ui.C) { diff --git a/desktop/internal/settingswindow/editor_view_test.go b/desktop/internal/settingswindow/editor_view_test.go index c83aaa98b..2ad85b1b9 100644 --- a/desktop/internal/settingswindow/editor_view_test.go +++ b/desktop/internal/settingswindow/editor_view_test.go @@ -8,8 +8,8 @@ import ( ) // TestEditorTabPlacementAppliesAndRestores walks the choices of the editor page's tab card: -// each one reaches its setter at once, the two choices for one row are inert while several -// rows are chosen, and Restore Defaults clears both settings. +// each choice reaches its setter, multiple rows retain overflow, and Restore Defaults +// clears both settings. func TestEditorTabPlacementAppliesAndRestores(t *testing.T) { var rows, overflow []string w := newWindow(NewController(Options{ @@ -20,30 +20,23 @@ func TestEditorTabPlacementAppliesAndRestores(t *testing.T) { d := openWindow(t, w) settings := w.text.Settings - if hit := d.hit(settings.TabsScroll); hit.Role != gfx.RoleRadio || !hit.Checked { - t.Fatalf("an unknown stored overflow does not show scrolling: %+v", hit) + if hit := d.hit(settings.TabsShowIn); hit.Role != gfx.RoleCombobox || hit.Value != settings.TabsScroll { + t.Fatalf("unknown layout did not show scrolling: %+v", hit) } + d.click(settings.TabsShowIn) d.click(settings.TabsSqueeze) if !slices.Equal(overflow, []string{TabOverflowSqueeze}) || len(rows) != 0 { - t.Fatalf("squeeze: rows=%v overflow=%v", rows, overflow) + t.Fatalf("squeeze rows=%v overflow=%v", rows, overflow) } + d.click(settings.TabsShowIn) d.click(settings.TabsMultipleRows) - if !slices.Equal(rows, []string{TabRowsMultiple}) { - t.Fatalf("multiple rows: rows=%v", rows) + if !slices.Equal(rows, []string{TabRowsMultiple}) || !slices.Equal(overflow, []string{TabOverflowSqueeze}) { + t.Fatalf("multiple rows changed the retained overflow: rows=%v overflow=%v", rows, overflow) } - if hit := d.hit(settings.TabsScroll); !hit.Disabled { - t.Fatalf("one-row choice is not disabled under several rows: %+v", hit) - } - d.click(settings.TabsScroll) - if !slices.Equal(overflow, []string{TabOverflowSqueeze}) { - t.Fatalf("a disabled choice applied: overflow=%v", overflow) - } - d.click(settings.TabsOneRow) - if !slices.Equal(rows, []string{TabRowsMultiple, ""}) { - t.Fatalf("one row: rows=%v", rows) - } - if hit := d.hit(settings.TabsSqueeze); hit.Disabled || !hit.Checked { - t.Fatalf("one row lost the squeeze choice: %+v", hit) + d.click(settings.TabsShowIn) + d.click(settings.TabsSqueeze) + if !slices.Equal(rows, []string{TabRowsMultiple, ""}) || d.hit(settings.TabsShowIn).Value != settings.TabsSqueeze { + t.Fatalf("returning to one row lost squeezing: rows=%v", rows) } d.click("Restore Defaults") if !slices.Equal(rows, []string{TabRowsMultiple, "", ""}) || !slices.Equal(overflow, []string{TabOverflowSqueeze, ""}) { diff --git a/desktop/internal/settingswindow/marketplace_view_test.go b/desktop/internal/settingswindow/marketplace_view_test.go index bfa92d9ef..baf3d2520 100644 --- a/desktop/internal/settingswindow/marketplace_view_test.go +++ b/desktop/internal/settingswindow/marketplace_view_test.go @@ -326,10 +326,10 @@ func TestMarketplaceDividerDragResizeAndRestore(t *testing.T) { if err := d.driver.Resize(760, 680); err != nil { t.Fatal(err) } - // The window leaves 523 pixels beside the sidebar: less the divider and the - // room the detail keeps. - if w.marketApplied != 355 { - t.Fatalf("narrow size = %v, want 355", w.marketApplied) + // The 234-point baseline sidebar leaves 525 pixels: less the divider + // and the room the detail keeps. + if w.marketApplied != 357 { + t.Fatalf("narrow size = %v, want 357", w.marketApplied) } checkDetail() // A click or outward drag at the temporary boundary must not erase the wider diff --git a/desktop/internal/settingswindow/palette.go b/desktop/internal/settingswindow/palette.go index 2925c4aed..d0c1b7a06 100644 --- a/desktop/internal/settingswindow/palette.go +++ b/desktop/internal/settingswindow/palette.go @@ -37,9 +37,9 @@ type palette struct { func lightPalette() palette { return palette{ - window: gfx.RGB24(0xffffff), sidebar: gfx.RGB24(0xececee), titleBar: gfx.RGB24(0xe8ebef), - card: gfx.RGB24(0xf7f8fa), - line: gfx.RGB24(0xd8dae0), border: gfx.RGB24(0xc9ccd6), + window: gfx.RGB24(0xf5f5f7), sidebar: gfx.RGB24(0xececee), titleBar: gfx.RGB24(0xffffff), + card: gfx.RGB24(0xffffff), + line: gfx.RGB24(0xededf0), border: gfx.RGB24(0xc9ccd6), hover: gfx.RGB24(0xf2f3f5), highlight: gfx.RGB24(0xddf4ff), selection: gfx.RGB24(0xD0DFFE), selectionIdle: gfx.RGB24(0xE9EAEE), @@ -52,9 +52,9 @@ func lightPalette() palette { func darkPalette() palette { return palette{ - window: gfx.RGB24(0x191a1c), sidebar: gfx.RGB24(0x232427), titleBar: gfx.RGB24(0x28292c), + window: gfx.RGB24(0x1c1d20), sidebar: gfx.RGB24(0x232427), titleBar: gfx.RGB24(0x191a1c), card: gfx.RGB24(0x2b2d30), - line: gfx.RGB24(0x2e3036), border: gfx.RGB24(0x4e5157), + line: gfx.RGB24(0x34363a), border: gfx.RGB24(0x4e5157), hover: gfx.RGB24(0x303136), highlight: gfx.RGB24(0x1f2a3a), selection: gfx.RGB24(0x2A4371), selectionIdle: gfx.RGB24(0x33353B), @@ -84,7 +84,7 @@ func windowTheme(dark bool, p palette) gfx.Theme { theme.Metrics = theme.Metrics.WithDensity(gfx.DensityCompact) theme.Background, theme.Foreground = p.window, p.text theme.Card, theme.CardForeground, theme.CardElevated = p.card, p.text, p.window - theme.Popover, theme.PopoverForeground = p.window, p.text + theme.Popover, theme.PopoverForeground = p.card, p.text theme.Muted, theme.MutedForeground = p.hover, p.muted theme.Border, theme.Input, theme.Hover = p.line, p.border, p.hover theme.Primary, theme.PrimaryForeground = p.accent, p.white diff --git a/desktop/internal/settingswindow/process_termination_view.go b/desktop/internal/settingswindow/process_termination_view.go index 3851e355c..f30b8dcba 100644 --- a/desktop/internal/settingswindow/process_termination_view.go +++ b/desktop/internal/settingswindow/process_termination_view.go @@ -33,34 +33,35 @@ func (w *window) processTerminationPage(c ui.C) { runServices, terminals := value.RunServicesOnExit, value.TerminalOnQuit confirmTab := value.ConfirmTerminalTabClose == nil || *value.ConfirmTerminalTabClose w.pageScroll(c, pageProcessTermination, func() { - w.card(c, i18n.Shared(language, "desktop.settings.processTermination.runServices"), i18n.Shared(language, "desktop.settings.processTermination.runServicesHelp"), func() { - label := func(choice string) string { - if choice == RunServicesTerminate { - return i18n.Shared(language, "desktop.settings.processTermination.stopWithoutAsking") + w.card(c, i18n.Shared(language, "native.whenQuittingLithe"), "", func() { + runTitle := i18n.Shared(language, "desktop.settings.processTermination.runServices") + w.settingsRow(c.Part("run-row"), runTitle, i18n.Shared(language, "desktop.settings.processTermination.runServicesHelp"), func() { + label := func(choice string) string { + if choice == RunServicesTerminate { + return i18n.Shared(language, "desktop.settings.processTermination.stopWithoutAsking") + } + return i18n.Shared(language, "desktop.settings.processTermination.askBeforeStopping") } - return i18n.Shared(language, "desktop.settings.processTermination.askBeforeStopping") - } - if ui.Select(c.Part("run"), &runServices, runServiceChoices, label).Width(selectWidth). - Label(i18n.Shared(language, "desktop.settings.processTermination.runServices")).Changed() { - value.RunServicesOnExit = runServices - w.setProcessTermination(value, false) - } - }) - w.card(c, i18n.Shared(language, "desktop.settings.processTermination.terminals"), i18n.Shared(language, "desktop.settings.processTermination.terminalsHelp"), func() { - label := func(choice string) string { - if choice == TerminalsTerminate { - return i18n.Shared(language, "desktop.settings.processTermination.endWithoutAsking") + if ui.Select(c.Part("run"), &runServices, runServiceChoices, label).Width(selectWidth).Label(runTitle).Changed() { + value.RunServicesOnExit = runServices + w.setProcessTermination(value, false) } - return i18n.Shared(language, "desktop.settings.processTermination.askBeforeEnding") - } - ui.Row(c).FillWidth().Height(28).Items(ui.Center).Gap(12).Children(func() { - ui.Text(c, i18n.Shared(language, "desktop.settings.processTermination.onQuit")).FontSize(12).Color(w.pal.text).Grow(1).MinWidth(0).Ellipsis() - if ui.Select(c.Part("quit"), &terminals, terminalChoices, label).Width(selectWidth). - Label(i18n.Shared(language, "desktop.settings.processTermination.onQuit")).Changed() { + }) + terminalTitle := i18n.Shared(language, "desktop.settings.processTermination.onQuit") + w.settingsRow(c.Part("terminal-row"), terminalTitle, i18n.Shared(language, "desktop.settings.processTermination.terminalsHelp"), func() { + label := func(choice string) string { + if choice == TerminalsTerminate { + return i18n.Shared(language, "desktop.settings.processTermination.endWithoutAsking") + } + return i18n.Shared(language, "desktop.settings.processTermination.askBeforeEnding") + } + if ui.Select(c.Part("quit"), &terminals, terminalChoices, label).Width(selectWidth).Label(terminalTitle).Changed() { value.TerminalOnQuit = terminals w.setProcessTermination(value, false) } }) + }) + w.card(c, i18n.Shared(language, "native.closingATerminalTab"), "", func() { if ui.Switch(c.Part("tab"), &confirmTab, i18n.Shared(language, "desktop.settings.processTermination.confirmTabClose")).Changed() { if confirmTab { value.ConfirmTerminalTabClose = nil diff --git a/desktop/internal/settingswindow/terminal_view.go b/desktop/internal/settingswindow/terminal_view.go index 94762dd53..b33316f5f 100644 --- a/desktop/internal/settingswindow/terminal_view.go +++ b/desktop/internal/settingswindow/terminal_view.go @@ -138,31 +138,37 @@ func (w *window) terminalPage(c ui.C) { } } w.pageScroll(c, pageTerminal, func() { - w.scrollbackCard(c) w.card(c, settings.TerminalShell, settings.TerminalShellHelp, func() { - ui.Column(c).FillWidth().Gap(2).Children(func() { - loading := !state.loaded - w.radio(c.Part("automatic"), &w.shellPick, "", settings.TerminalShellAuto(state.effective(settings)), loading) - if loading { - // Whether a stored shell is installed is not known yet. It stays chosen, - // named by its path, and cannot be clicked until the list is here. - if stored != "" { - w.radio(c.Part("missing"), &w.shellPick, stored, stored, true) - } - return + choices := make([]string, 1, len(state.shells)+2) + labels := state.shellLabels(settings) + known := stored == "" + for _, choice := range state.shells { + choices = append(choices, choice.Path) + known = known || (stored != "" && sameShell(choice.Path, stored)) + } + if !known { + choices = append(choices, stored) + } + label := func(path string) string { + if path == "" { + return settings.TerminalShellAuto(state.effective(settings)) } - labels := state.shellLabels(settings) - known := stored == "" - for index, choice := range state.shells { - known = known || (stored != "" && sameShell(choice.Path, stored)) - w.radio(c.Key(choice.Path), &w.shellPick, choice.Path, labels[index], false) + for i, choice := range state.shells { + if path == choice.Path { + return labels[i] + } } - if !known { - // Already chosen, so a click on it changes nothing. - w.radio(c.Part("missing"), &w.shellPick, stored, settings.TerminalShellMissing(stored), false) + if !state.loaded { + return path } + return settings.TerminalShellMissing(path) + } + ui.Row(c).FillWidth().Height(28).Items(ui.Center).Gap(12).Children(func() { + ui.Text(c, settings.TerminalShell).FontSize(12).Color(w.pal.text).Grow(1).MinWidth(0).Ellipsis() + ui.Select(c.Part("shell"), &w.shellPick, choices, label).Width(selectWidth).Label(settings.TerminalShell).Disabled(!state.loaded) }) }) + w.scrollbackCard(c) }) if w.shellPick != stored && !(stored != "" && sameShell(w.shellPick, stored)) { w.setTerminalShell(w.shellPick, false) diff --git a/desktop/internal/settingswindow/terminal_view_test.go b/desktop/internal/settingswindow/terminal_view_test.go index 3912112c7..67a214a8c 100644 --- a/desktop/internal/settingswindow/terminal_view_test.go +++ b/desktop/internal/settingswindow/terminal_view_test.go @@ -32,13 +32,15 @@ func TestTerminalShellChoiceWritesOnlyWhenChosen(t *testing.T) { t.Fatalf("opening the page wrote %v", changed) } auto := settings.TerminalShellAuto("zsh") - if hit := d.hit(auto); !hit.Checked { + if hit := d.hit(settings.TerminalShell); hit.Value != auto { t.Fatalf("automatic is not chosen: %+v", hit) } + d.click(settings.TerminalShell) d.click("bash") if !slices.Equal(changed, []string{"/bin/bash"}) { t.Fatalf("bash: %v", changed) } + d.click(settings.TerminalShell) d.click(auto) if !slices.Equal(changed, []string{"/bin/bash", ""}) { t.Fatalf("automatic: %v", changed) @@ -52,7 +54,7 @@ func TestTerminalStoredShellThatIsGoneStaysChosen(t *testing.T) { var changed []string w, d := terminalWindow(t, "/opt/gone/fish", &changed) missing := w.text.Settings.TerminalShellMissing("/opt/gone/fish") - if hit := d.hit(missing); !hit.Checked { + if hit := d.hit(w.text.Settings.TerminalShell); hit.Value != missing { t.Fatalf("the stored shell is not chosen: %+v", hit) } if len(changed) != 0 { diff --git a/desktop/internal/settingswindow/ui_baseline_test.go b/desktop/internal/settingswindow/ui_baseline_test.go new file mode 100644 index 000000000..6b3210b04 --- /dev/null +++ b/desktop/internal/settingswindow/ui_baseline_test.go @@ -0,0 +1,37 @@ +package settingswindow + +import ( + "testing" + + "github.com/vivaui/viva/gfx" +) + +func TestBaselineSettingsSurfaceHierarchy(t *testing.T) { + for _, test := range []struct { + dark bool + page, card gfx.Color + }{ + {false, gfx.RGB24(0xf5f5f7), gfx.RGB24(0xffffff)}, + {true, gfx.RGB24(0x1c1d20), gfx.RGB24(0x2b2d30)}, + } { + p := paletteFor(test.dark) + if p.window != test.page || p.card != test.card { + t.Fatalf("dark=%v: page=%v card=%v", test.dark, p.window, p.card) + } + } +} + +func TestBaselineSettingsControlsFitReferenceWindow(t *testing.T) { + w := newWindow(NewController(Options{}), Values{Theme: "dark", Language: "en"}) + d := openWindow(t, w) + shot := d.driver.Snapshot() + if shot.Width != 900 || shot.Height != 700 { + t.Fatalf("initial window=%dx%d, want 900x700", shot.Width, shot.Height) + } + for _, label := range []string{"Search settings", "Language", "Cancel", "OK"} { + hit := d.hit(label) + if hit.Bounds.X < 0 || hit.Bounds.Y < 0 || hit.Bounds.X+hit.Bounds.Width > 900 || hit.Bounds.Y+hit.Bounds.Height > 700 { + t.Fatalf("%s outside reference window: %v", label, hit.Bounds) + } + } +} diff --git a/desktop/internal/settingswindow/updates_view.go b/desktop/internal/settingswindow/updates_view.go index 38d9f52ce..01e27f9ee 100644 --- a/desktop/internal/settingswindow/updates_view.go +++ b/desktop/internal/settingswindow/updates_view.go @@ -39,9 +39,11 @@ func (w *window) updatesPage(c ui.C) { opts := &w.ctrl.opts status := opts.UpdateStatus() w.pageScroll(c, pageUpdates, func() { + w.card(c, i18n.Shared(language, "native.currentVersion"), "", func() { + ui.Text(c, opts.Version).FontSize(13).Color(w.pal.text) + }) w.card(c, i18n.Shared(language, "desktop.settings.updates.title"), i18n.Shared(language, "desktop.settings.updates.help"), func() { ui.Column(c).FillWidth().Gap(8).Children(func() { - ui.Text(c, w.text.App.Version(opts.Version)).FontSize(13).Color(w.pal.text) if !status.Configured { ui.Wrap(c, i18n.Shared(language, "desktop.settings.updates.notConfigured")).FontSize(12).Color(w.pal.muted) return diff --git a/desktop/internal/settingswindow/visual_capture_test.go b/desktop/internal/settingswindow/visual_capture_test.go new file mode 100644 index 000000000..85553dcb5 --- /dev/null +++ b/desktop/internal/settingswindow/visual_capture_test.go @@ -0,0 +1,135 @@ +//go:build vivahead && lithevisual + +package settingswindow + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/1lck/Lithe-IDEA/desktop/internal/localshell" + "github.com/1lck/Lithe-IDEA/desktop/internal/marketplace" + "github.com/vivaui/viva/gfx" + "github.com/vivaui/viva/headless" +) + +// TestVisualSettings renders the production Page with isolated, offline facts. +// Opt in with -tags 'vivahead lithevisual' and LITHE_VISUAL_OUTPUT_DIR. +// LITHE_VISUAL_LANGUAGE optionally selects another supported UI language. +func TestVisualSettings(t *testing.T) { + out := os.Getenv("LITHE_VISUAL_OUTPUT_DIR") + if out == "" { + t.Skip("set LITHE_VISUAL_OUTPUT_DIR to capture the production settings pages") + } + if err := os.MkdirAll(out, 0755); err != nil { + t.Fatal(err) + } + language := i18n.Chinese + if code := os.Getenv("LITHE_VISUAL_LANGUAGE"); code != "" { + var ok bool + language, ok = i18n.Parse(code) + if !ok { + t.Fatalf("unsupported visual capture language %q", code) + } + } + pages := []struct { + id string + page pageID + }{ + {"20-settings-general", pageGeneral}, {"22-settings-editor", pageEditor}, + {"23-settings-keymap", pageKeymap}, {"24-settings-network", pageNetwork}, + {"25-settings-process", pageProcessTermination}, {"26-settings-git", pageVersionControl}, + {"27-settings-terminal", pageTerminal}, {"28-plugins-installed", pagePlugins}, + {"29-marketplace-empty", pagePlugins}, {"31-settings-updates", pageUpdates}, + {"33-settings-toolchains", pageBuild}, {"fonts", pageFonts}, + {"21-settings-language-popup", pageGeneral}, {"30-settings-history", pageGeneral}, {"35-settings-search", pageEditor}, + {"m35-editor-tab-layout", pageEditor}, {"m25-project-open-mode", pageGeneral}, {"m29-editor-font-menu", pageFonts}, {"m38-plugin-management-menu", pagePlugins}, + {"m39-proxy-mode-menu", pageNetwork}, {"m40-process-run-policy", pageProcessTermination}, + {"m41-process-terminal-policy", pageProcessTermination}, {"m43-process-orphan-policy", pageProcessTermination}, + } + for _, theme := range []string{"dark", "light"} { + for _, scenario := range pages { + t.Run(scenario.id+"-"+theme, func(t *testing.T) { + opts := Options{ + Text: func() *i18n.Messages { return i18n.For(language) }, Language: func() i18n.Language { return language }, + SystemLanguage: language, Version: "0.1.0", + InstalledPlugins: func() []PluginFacts { + return []PluginFacts{ + {ID: "dev.lithe.editor-shortcut-import", Name: "Editor Shortcut Import", Vendor: "Lithe", Version: "1.0.0", Source: PluginSourceMarketplace}, + {ID: "dev.lithe.java", Name: "Java Support", Vendor: "Lithe", Version: "1.0.0", Source: PluginSourceMarketplace}, + } + }, + PluginState: func(string) PluginState { return PluginState{Installed: true, Enabled: true, Version: "1.0.0"} }, SystemDark: func() bool { return theme == "dark" }, + SetNetwork: func(NetworkSettings, *string) {}, SetLocalHistory: func(LocalHistorySettings) {}, + StopTime: func() (StopTime, bool) { return StopTime{Default: 5, Min: 1, Max: 30}, true }, SweepsLeftovers: true, + TerminalShells: func() ([]localshell.Choice, string) { + return []localshell.Choice{{Name: "zsh", Path: "/bin/zsh", Default: true}, {Name: "bash", Path: "/bin/bash"}}, "/bin/zsh" + }, + MonospaceFamilies: func() []string { return []string{"JetBrains Mono", "Menlo"} }, + Marketplace: func(context.Context) (marketplace.Snapshot, error) { return marketplace.Snapshot{}, nil }, + UpdateStatus: func() UpdateStatus { return UpdateStatus{Configured: true} }, + Toolchains: &fakeToolchainLoader{snapshot: fixtureSnapshot()}, + } + w := newWindow(NewController(opts), Values{Theme: theme, Language: language.Code(), TerminalScrollback: 2000}) + w.sess.page = scenario.page + if scenario.id == "29-marketplace-empty" { + w.pluginTab = pluginTabMarketplace + } + session, err := headless.Open(w.config(), nil, headless.Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(session.Close) + d := &windowDriver{t: t, w: w, driver: session.Driver()} + switch scenario.page { + case pageTerminal: + d.until("shell choices", func() bool { return w.terminal.loaded }) + case pageBuild: + d.until("toolchain facts", func() bool { return w.toolchains.loaded }) + case pagePlugins: + d.until("offline index", func() bool { return w.market.status == marketReady }) + case pageFonts: + d.until("font families", func() bool { return w.fonts.loaded }) + } + text := func(key string) string { return i18n.Shared(language, key) } + switch scenario.id { + case "m35-editor-tab-layout": + d.click(w.text.Settings.TabsShowIn) + case "21-settings-language-popup": + d.click(w.text.Settings.Language) + case "30-settings-history": + if err := d.driver.Scroll(gfx.ScrollEvent{X: 700, Y: 500, DeltaY: 500, Viewport: gfx.Size{Width: 900, Height: 700}}); err != nil { + t.Fatal(err) + } + case "35-settings-search": + query := "编辑器" + if language == i18n.English { + query = "Editor" + } + w.searchField.SetText(query) + d.pump() + case "m25-project-open-mode": + d.click(text("desktop.settings.projectOpen")) + case "m29-editor-font-menu": + d.click(text("settings.editor.fontFamily")) + d.key(gfx.KeyDown) + case "m38-plugin-management-menu": + d.click(text("desktop.pluginSettings.title")) + case "m39-proxy-mode-menu": + d.click(text("desktop.settings.network.mode")) + case "m40-process-run-policy": + d.click(text("desktop.settings.processTermination.runServices")) + case "m41-process-terminal-policy": + d.click(text("desktop.settings.processTermination.onQuit")) + case "m43-process-orphan-policy": + d.click(text("desktop.settings.processTermination.leftoverTerminals")) + } + if err := session.Screenshot(filepath.Join(out, scenario.id+"-"+theme+".png")); err != nil { + t.Fatal(err) + } + }) + } + } +} diff --git a/desktop/internal/settingswindow/window.go b/desktop/internal/settingswindow/window.go index 6a2926df1..77750a786 100644 --- a/desktop/internal/settingswindow/window.go +++ b/desktop/internal/settingswindow/window.go @@ -16,26 +16,26 @@ import ( // The metrics of the window, after the macOS client's settings window and the log window. const ( - windowWidth, windowHeight = 1040, 720 - windowMinWidth = 760 - windowMinHeight = 520 + windowWidth, windowHeight = 900, 700 + windowMinWidth = 900 + windowMinHeight = 668 // titleHeight is the height of the title bar, which the system's window buttons sit in. - titleHeight = 28 + titleHeight = 32 // sidebarWidth is the width of the column of pages. - sidebarWidth = 236 + sidebarWidth = 234 // searchHeight is the height of the search field. searchHeight = 28 // rowHeight is the height of a row of the sidebar. - rowHeight = 28 + rowHeight = 24 // chevronSlot is the room a group's chevron takes in front of a row, which the rows that // are not groups keep empty so every tile lines up. chevronSlot = 24 chevronGlyph = 12 // tileSize is the rounded square in front of a top-level row, tileGlyph the white glyph // in it. - tileSize = 18 + tileSize = 20 tileRadius = 4 tileGlyph = 12 // childIndent is what a row inside a group is indented by, so its title lines up with @@ -43,21 +43,18 @@ const ( childIndent = chevronSlot + tileSize + 8 // contentPad is the margin of the page at its sides. - contentPad = 24 + contentPad = 26 // The cards of a page. - cardRadius = 8 + cardRadius = 10 cardPad = 14 cardGap = 12 // selectWidth is the width of the language list. selectWidth = 190 // radioHeight is the height of one option of a radio group, radioSize the circle in // front of its words and radioDot the dot that fills the circle of the chosen one. - // radioIndent is how far an option that refines another is set in, so its circle sits - // under the words of the option it refines. radioHeight = 24 radioSize = 14 radioDot = 6 - radioIndent = radioSize + 8 // emptyWidth is the width the sentence of a page that is not available yet wraps at. emptyWidth = 360 // footerHeight is the height of the bar with the buttons. diff --git a/desktop/internal/settingswindow/window_view.go b/desktop/internal/settingswindow/window_view.go index 3f9e79533..7a44425bd 100644 --- a/desktop/internal/settingswindow/window_view.go +++ b/desktop/internal/settingswindow/window_view.go @@ -75,7 +75,7 @@ func (w *window) sidebar(c ui.C) { p := &w.pal settings := &w.text.Settings ui.Column(c).Width(sidebarWidth).FillHeight().Shrink(0).Background(p.sidebar).Clip().Children(func() { - ui.Row(c).FillWidth().Shrink(0).PadSides(10, 12, 8, 12).Children(func() { + ui.Row(c).FillWidth().Shrink(0).PadSides(10, 10, 8, 10).Children(func() { search := ui.TextFieldID(c, searchID, &w.searchField, settings.Search, settings.Search).Icon(icons.Search).FillWidth().Height(searchHeight) if w.searchField.Text() != "" { search = search.Action(icons.X, i18n.Shared(w.marketLanguage(), "desktop.logs.clearSearch")) @@ -101,7 +101,7 @@ func (w *window) sidebar(c ui.C) { }) return } - ui.Tree(c, &w.nav, len(w.rows), w.navLabel, func(i int) { w.navContent(c, i) }).Grow(1).Shrink(1).MinHeight(0).Label(settings.Categories) + ui.Tree(c, &w.nav, len(w.rows), w.navLabel, func(i int) { w.navContent(c, i) }).Grow(1).Shrink(1).MinHeight(0).PadSides(12, 12, 0, 12).Label(settings.Categories) if i, ok := w.nav.Toggled(); ok && i < len(w.rows) { w.toggleRow(w.rows[i]) } @@ -231,7 +231,7 @@ func (w *window) breadcrumb(c ui.C) { if page := w.dynamic.selected(w.dynamicPage); page != nil { w.crumbs = append(w.crumbs, page.Title) } - ui.Row(c).FillWidth().Height(46).Shrink(0).PadX(contentPad).Gap(6).Items(ui.Center).Background(p.card).Children(func() { + ui.Row(c).FillWidth().Height(46).Shrink(0).PadX(18).Gap(6).Items(ui.Center).Background(p.card).Children(func() { for i, crumb := range w.crumbs { if i > 0 { ui.Icon(c, icons.ChevronRight).Size(chevronGlyph, chevronGlyph).Color(p.subtle).Shrink(0) @@ -259,7 +259,7 @@ func (w *window) unavailablePage(c ui.C) { // pageScroll is the scroll area of one page. It is named by the page, so it starts at its // top each time the page is opened. func (w *window) pageScroll(c ui.C, page pageID, body func()) { - ui.Scroll(c.KeyInt(int(page))).Grow(1).Shrink(1).MinHeight(0).PadSides(18, contentPad, contentPad, contentPad).Gap(cardGap).Children(body) + ui.Scroll(c.KeyInt(int(page))).Grow(1).Shrink(1).MinHeight(0).PadSides(20, contentPad, 36, contentPad).Gap(cardGap).Children(body) } // card puts the section label outside its bounded settings rows, as in the native client. @@ -273,7 +273,20 @@ func (w *window) card(c ui.C, title, help string, body func()) { } }) ui.Column(c).FillWidth().Shrink(0).MinWidth(0).PadX(cardPad).PadY(7).Radius(cardRadius). - Background(p.card).Stroke(1, p.line).Children(body) + Background(p.card).Stroke(0.5, p.line).Children(body) + }) +} + +// settingsRow keeps compact forms on the same label/control columns in every page. +func (w *window) settingsRow(c ui.C, title, help string, body func()) { + ui.Row(c).FillWidth().Height(28).Items(ui.Center).Gap(12).Children(func() { + ui.Row(c).WidthPercent(40).Shrink(1).MinWidth(0).Items(ui.Center).Gap(6).Children(func() { + ui.Text(c, title).FontSize(12).Color(w.pal.text).Ellipsis().Shrink(1) + if help != "" { + w.infoButton(c, help) + } + }) + ui.Row(c).Grow(1).Shrink(1).MinWidth(0).Items(ui.Center).Children(body) }) } @@ -338,7 +351,7 @@ func (w *window) generalPage(c ui.C) { choice := func() { ui.Choice(c, &w.themePick, themeValues[:], w.themeLabel).Width(420).Shrink(1).MinWidth(0).Label(settings.AppearanceMode) } - if w.extent-2*contentPad-2*cardPad < 590 { + if w.extent-2*contentPad-2*cardPad < 460 { ui.Column(c).FillWidth().Gap(8).Children(func() { ui.Text(c, settings.AppearanceMode).FontSize(12).Color(p.text) choice() @@ -364,13 +377,15 @@ func (w *window) generalPage(c ui.C) { w.card(c, settings.Workbench, settings.LayoutHint, func() { language := w.marketLanguage() colors := !w.sess.values.HideProjectColors - if ui.Switch(c.Part("project-colors"), &colors, i18n.Shared(language, "desktop.settings.projectColors")).Changed() { - w.sess.values.HideProjectColors = !colors - if w.ctrl.opts.SetHideProjectColors != nil { - w.ctrl.opts.SetHideProjectColors(!colors) + ui.Row(c).FillWidth().Height(28).Items(ui.Center).Gap(6).Children(func() { + if ui.Switch(c.Part("project-colors"), &colors, i18n.Shared(language, "desktop.settings.projectColors")).Changed() { + w.sess.values.HideProjectColors = !colors + if w.ctrl.opts.SetHideProjectColors != nil { + w.ctrl.opts.SetHideProjectColors(!colors) + } } - } - ui.Wrap(c, i18n.Shared(language, "desktop.settings.projectColorsHelp")).FontSize(12).Color(p.muted) + w.infoButton(c, i18n.Shared(language, "desktop.settings.projectColorsHelp")) + }) ui.Row(c).FillWidth().Height(28).Items(ui.Center).Gap(12).Children(func() { label := i18n.Shared(language, "desktop.settings.projectOpen") ui.Row(c).WidthPercent(34).Shrink(1).MinWidth(0).Items(ui.Center).Children(func() { @@ -395,6 +410,7 @@ func (w *window) generalPage(c ui.C) { } }) }) + w.autoSaveCard(c) if w.ctrl.opts.SetLocalHistory != nil { w.localHistoryCard(c) } @@ -425,27 +441,30 @@ func (w *window) languageLabel(code string) string { // editorPage is the editor page. Its card says where an editor group's tabs go when they // do not all fit, as the IDE's setting does: one row that scrolls, one row whose tabs are -// squeezed, or as many rows as they need. The two choices for one row refine the first -// option and are set in under it; they are dimmed while several rows are chosen, which -// keeps what was picked for one row for when the user goes back to it. +// squeezed, or as many rows as they need. One shared selector presents the three layouts; +// choosing several rows retains the user's last overflow setting. func (w *window) editorPage(c ui.C) { - p := &w.pal settings := &w.text.Settings values := &w.sess.values w.tabRowsPick, w.tabOverflowPick = values.EditorTabRows, values.EditorTabOverflow w.pageScroll(c, pageEditor, func() { w.card(c, settings.EditorTabs, settings.EditorTabsHelp, func() { - ui.Column(c).FillWidth().Gap(2).Children(func() { - ui.Row(c).FillWidth().Height(radioHeight).Items(ui.Center).Children(func() { - ui.Text(c, settings.TabsShowIn).FontSize(12).Color(p.text).Ellipsis() - }) - multiple := values.EditorTabRows == TabRowsMultiple - w.radio(c.Part("one-row"), &w.tabRowsPick, "", settings.TabsOneRow, false) - ui.Column(c).FillWidth().PadSides(0, 0, 0, radioIndent).Gap(2).Children(func() { - w.radio(c.Part("scroll"), &w.tabOverflowPick, "", settings.TabsScroll, multiple) - w.radio(c.Part("squeeze"), &w.tabOverflowPick, TabOverflowSqueeze, settings.TabsSqueeze, multiple) - }) - w.radio(c.Part("several-rows"), &w.tabRowsPick, TabRowsMultiple, settings.TabsMultipleRows, false) + type placement struct{ rows, overflow string } + pick := placement{w.tabRowsPick, w.tabOverflowPick} + options := []placement{{"", ""}, {"", TabOverflowSqueeze}, {TabRowsMultiple, w.tabOverflowPick}} + label := func(option placement) string { + if option.rows == TabRowsMultiple { + return settings.TabsMultipleRows + } + if option.overflow == TabOverflowSqueeze { + return settings.TabsSqueeze + } + return settings.TabsScroll + } + w.settingsRow(c, settings.TabsShowIn, "", func() { + if ui.Select(c.Part("tab-layout"), &pick, options, label).Width(selectWidth).Label(settings.TabsShowIn).Changed() { + w.tabRowsPick, w.tabOverflowPick = pick.rows, pick.overflow + } }) }) w.tabLimitCard(c) @@ -455,7 +474,6 @@ func (w *window) editorPage(c ui.C) { w.setDisableSemanticHighlight(!semantic, false) } }) - w.autoSaveCard(c) w.vimCard(c) }) if w.tabRowsPick != values.EditorTabRows { @@ -470,7 +488,7 @@ func (w *window) editorPage(c ui.C) { // at the trailing one. func (w *window) footer(c ui.C) { settings := &w.text.Settings - ui.Row(c).FillWidth().Height(footerHeight).Shrink(0).PadX(footerPad).Gap(8).Items(ui.Center).Children(func() { + ui.Row(c).FillWidth().Height(footerHeight).Shrink(0).Background(w.pal.card).PadX(footerPad).Gap(8).Items(ui.Center).Children(func() { if ui.Button(c, settings.RestoreDefaults).Outline().Clicked() { // Reset is explicit even when an unknown stored value is displayed as the // system default. Forward it so the owner's real setting is cleared. diff --git a/desktop/internal/workbench/app_test.go b/desktop/internal/workbench/app_test.go index 5454da268..0e2084f02 100644 --- a/desktop/internal/workbench/app_test.go +++ b/desktop/internal/workbench/app_test.go @@ -79,6 +79,17 @@ func newHarnessWithLanguages(t testing.TB, kernel Kernel, languages []string) *h // the store and plugin root; the options' Now is replaced with the driver's // virtual clock so frame counters stay deterministic. func newHarnessWithOptions(t testing.TB, options Options) *harness { + return newHarnessWithDriver(t, options, func(config app.Config) (*app.Driver, func(), error) { + driver, err := app.NewDriver(config, nil) + if err != nil { + return nil, nil, err + } + return driver, driver.Close, nil + }) +} + +// newHarnessWithDriver gives visual tests the same lifecycle as behavioral tests. +func newHarnessWithDriver(t testing.TB, options Options, open func(app.Config) (*app.Driver, func(), error)) *harness { t.Helper() h := &harness{t: t, store: options.Store} start := time.Unix(1_700_000_000, 0) @@ -90,7 +101,7 @@ func newHarnessWithOptions(t testing.TB, options Options) *harness { } h.app = New(options) h.config = h.app.Config() - driver, err := app.NewDriver(h.config, nil) + driver, closeDriver, err := open(h.config) if err != nil { t.Fatalf("NewDriver: %v", err) } @@ -124,7 +135,7 @@ func newHarnessWithOptions(t testing.TB, options Options) *harness { h.app.project = nil } h.app.wait() - driver.Close() + closeDriver() }) return h } @@ -243,9 +254,11 @@ func TestWorkbenchDefaultLayout(t *testing.T) { } } } - if !h.app.chrome.isOpen(catalogIndex[itemProject]) || !h.app.chrome.isOpen(catalogIndex[itemGit]) { - t.Errorf("open tool windows = %v, want the project and Git windows", h.app.chrome.open) + if !h.app.chrome.isOpen(catalogIndex[itemProject]) || h.app.chrome.isOpen(catalogIndex[itemGit]) { + t.Errorf("open tool windows = %v, want only Project before a tool is requested", h.app.chrome.open) } + h.click(center(h.button(itemGit))) + h.settleChain() for _, text := range []string{"preview", "2 个更改", "LSP 未启动", "日志", "工作树", "控制台"} { if !h.shows(text) { t.Errorf("the workbench does not show %q", text) @@ -466,6 +479,9 @@ func TestDetachedStripeDragTargetsDockAreasAndOtherStripeButtons(t *testing.T) { kernel := &testKernel{Client: core.NewClient()} h := newHarness(t, kernel) h.open(t.TempDir()) + // This geometry case exercises the bottom dock with Git explicitly open. + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() kernel.chromeActions = nil from := center(h.button(itemNotifications)) diff --git a/desktop/internal/workbench/catalog.go b/desktop/internal/workbench/catalog.go index b0893cb69..470efcf54 100644 --- a/desktop/internal/workbench/catalog.go +++ b/desktop/internal/workbench/catalog.go @@ -210,7 +210,7 @@ var catalog = []item{ {id: itemStructure, zone: zoneLeftTop, icon: &icons.ListTree, label: func(m *i18n.Messages) string { return m.Tools.Structure }, panel: true}, {id: itemFind, zone: zoneLeftTop, icon: &icons.Search, label: func(m *i18n.Messages) string { return m.Tools.Find }, panel: true}, - {id: itemGit, zone: zoneBottomLeft, icon: &icons.GitBranch, label: func(m *i18n.Messages) string { return m.Tools.Git }, panel: true, open: true}, + {id: itemGit, zone: zoneBottomLeft, icon: &icons.GitBranch, label: func(m *i18n.Messages) string { return m.Tools.Git }, panel: true}, {id: itemTerminal, zone: zoneBottomLeft, icon: &icons.SquareTerminal, label: func(m *i18n.Messages) string { return m.Tools.Terminal }, panel: true}, {id: itemProblems, zone: zoneBottomLeft, icon: &icons.CircleAlert, label: func(m *i18n.Messages) string { return m.Tools.Problems }, panel: true}, {id: itemReferences, zone: zoneBottomLeft, icon: &icons.Locate, label: func(m *i18n.Messages) string { return m.Tools.References }, panel: true}, diff --git a/desktop/internal/workbench/chrome_menu_test.go b/desktop/internal/workbench/chrome_menu_test.go index b1b2add74..871ee4cd9 100644 --- a/desktop/internal/workbench/chrome_menu_test.go +++ b/desktop/internal/workbench/chrome_menu_test.go @@ -66,3 +66,15 @@ func TestToolWindowHeaderMenuHidesTheToolWindow(t *testing.T) { t.Fatal("Hide Tool Window left the Problems tool window open") } } + +func TestToolWindowMoveSubmenuUsesCoreLayout(t *testing.T) { + h := newHarness(t, &testKernel{Client: core.NewClient()}) + h.open(t.TempDir()) + h.rightClickAt(h.button(itemProject)) + h.clickNamed(h.shared("desktop.tools.moveTo")) + h.clickNamed(stripeZoneMoveText(h.app.language(), zoneRightTop)) + h.settleChain() + if got := h.app.chrome.zoneOf(catalogIndex[itemProject]); got != zoneRightTop { + t.Fatalf("move submenu left Project in zone %v", got) + } +} diff --git a/desktop/internal/workbench/chrome_view.go b/desktop/internal/workbench/chrome_view.go index a17c6a4ee..d7b1d2f44 100644 --- a/desktop/internal/workbench/chrome_view.go +++ b/desktop/internal/workbench/chrome_view.go @@ -38,7 +38,11 @@ func (a *App) chromeBox(row ui.Box, id gfx.ID, i int, open bool, value string) u HitWith(id, label, options). Pressed(ui.Look{}.Background(a.colors.Active)) if open { - row = row.Background(a.colors.Active) + fill := a.colors.Active + if it.zone.surface() == surfaceStripes { + fill = a.theme.Primary + } + row = row.Background(fill) } else { row = row.Hover(ui.Look{}.Background(a.colors.Hover)) } @@ -242,9 +246,13 @@ func (a *App) stripeZone(c ui.C, z zone, dragging bool) int { open := a.chrome.isOpen(i) rest := a.colors.Subtle if open { - rest = a.colors.Text + rest = a.theme.PrimaryForeground + } + hover := a.colors.Text + if open { + hover = a.theme.PrimaryForeground } - box, hit := a.chromeGlyph(c, i, chromeToolButton, rest, a.colors.Text, open) + box, hit := a.chromeGlyph(c, i, chromeToolButton, rest, hover, open) if hit { clicked = i } @@ -576,18 +584,20 @@ func (a *App) stripeButtonMenu(c ui.C, i int, at zone) func(*ui.MenuItems) { } menu.Separator() } - for z := zoneLeftTop; z <= zoneBottomRight; z++ { - if z == at { - continue - } - target := zoneNames[z] - if menu.Item(stripeZoneMoveText(language, z)).Clicked() { - c.After(func() { - a.performed("workbench.toolWindow.move", surfaceMenu) - a.reduceChrome(core.MoveChrome(id, target, "")) - }) + menu.Sub(i18n.Shared(language, "desktop.tools.moveTo"), func(sub *ui.MenuItems) { + for z := zoneLeftTop; z <= zoneBottomRight; z++ { + if z == at { + continue + } + target := zoneNames[z] + if sub.Item(stripeZoneMoveText(language, z)).Clicked() { + c.After(func() { + a.performed("workbench.toolWindow.move", surfaceMenu) + a.reduceChrome(core.MoveChrome(id, target, "")) + }) + } } - } + }) // Move Earlier and Move Later swap the button with its neighbour in its own zone: the // button goes in front of the one before it, or in front of the one after the next. items := a.chrome.items[at] diff --git a/desktop/internal/workbench/context_menu_test.go b/desktop/internal/workbench/context_menu_test.go index a54687b5d..b89cb56f0 100644 --- a/desktop/internal/workbench/context_menu_test.go +++ b/desktop/internal/workbench/context_menu_test.go @@ -100,3 +100,17 @@ func TestRunConsoleMenuCopiesTheChosenLineAndTheWholeOutput(t *testing.T) { t.Fatalf("Copy Output put %q on the clipboard, want the whole output", got) } } + +func TestEditorCopyPathSubmenuKeepsAbsoluteAndRelativeActions(t *testing.T) { + h := newHarness(t, gitKernel{core.NewClient()}) + root := newTestRepository(t) + p := h.openTree(t, root) + h.app.openFile(p, filepath.Join(root, "changed.txt")) + h.settleChain() + h.rightClickAt(h.tabHit("changed.txt")) + h.clickNamed(h.shared("native.copyPathReference")) + h.clickNamed(h.shared("native.copyRelativePath")) + if got := h.driver.Clipboard(); got != "changed.txt" { + t.Fatalf("copied relative path %q", got) + } +} diff --git a/desktop/internal/workbench/difftab.go b/desktop/internal/workbench/difftab.go index da8e46940..3eeb89ee6 100644 --- a/desktop/internal/workbench/difftab.go +++ b/desktop/internal/workbench/difftab.go @@ -134,6 +134,8 @@ type diffTab struct { split, whole bool // ignoreSpace reads the diff ignoring all whitespace (see setDiffWhitespace). ignoreSpace bool + // settingsOpen belongs to this tab, so closing it releases its menu. + settingsOpen bool // expanded are the folds the user opened, by the unified index of the first unchanged line // of the run they hide, which is the same in both modes. expanded map[int]diffReveal diff --git a/desktop/internal/workbench/difftab_test.go b/desktop/internal/workbench/difftab_test.go index 6fc4bf268..98400f154 100644 --- a/desktop/internal/workbench/difftab_test.go +++ b/desktop/internal/workbench/difftab_test.go @@ -393,3 +393,25 @@ func TestDiffTabFindsIgnoresWhitespaceAndEditsTheWorkingCopy(t *testing.T) { t.Fatalf("the document did not get the side's edit: %q", text[:min(len(text), 30)]) } } + +func TestDiffSettingsMenuUsesExistingWhitespaceAndCloseActions(t *testing.T) { + root := newTestRepository(t) + h := newHarness(t, gitKernel{core.NewClient()}) + p := h.openCommit(root) + tab := openDiffTab(t, h, p, "changed.txt") + h.clickNamed(h.shared("native.diffSettings")) + if !tab.settingsOpen { + t.Fatal("the settings menu did not open") + } + h.clickNamed(h.shared("git.diff.ignoreWhitespace")) + h.await("whitespace diff", func() bool { return !tab.loading && tab.ignoreSpace }) + h.clickNamed(h.shared("native.diffSettings")) + h.clickNamed(h.shared("desktop.diff.keepWhitespace")) + h.await("whitespace restored", func() bool { return !tab.loading && !tab.ignoreSpace }) + h.clickNamed(h.shared("native.diffSettings")) + h.clickNamed(h.shared("native.closeDiff")) + h.settleChain() + if len(p.diffs.list) != 0 { + t.Fatal("Close diff kept the tab alive") + } +} diff --git a/desktop/internal/workbench/difftab_view.go b/desktop/internal/workbench/difftab_view.go index 782d49686..9de9c2b19 100644 --- a/desktop/internal/workbench/difftab_view.go +++ b/desktop/internal/workbench/difftab_view.go @@ -116,9 +116,7 @@ func (a *App) diffToolbar(c ui.C, p *project, t *diffTab) { } ui.Text(c, count).FontSize(size).Color(a.colors.Subtle).Shrink(0) } - if a.gitIconButton(c, i18n.Shared(lang, "git.diff.ignoreWhitespace"), icons.Eraser, t.ignoreSpace, t.saved != nil) { - c.After(func() { a.setDiffWhitespace(p, t, !t.ignoreSpace) }) - } + ui.Row(c).Shrink(0).Items(ui.Center).Gap(2).Label(i18n.Shared(lang, "desktop.gitReview.diffTab")).Children(func() { if a.gitIconButton(c, i18n.Shared(lang, "git.diff.split"), icons.Columns2, t.split, false) && !t.split { c.After(func() { a.setDiffMode(p, t, true, t.whole) }) @@ -127,6 +125,28 @@ func (a *App) diffToolbar(c ui.C, p *project, t *diffTab) { c.After(func() { a.setDiffMode(p, t, false, t.whole) }) } }) + label := i18n.Shared(lang, "native.diffSettings") + gear, clicked := a.toolIconButton(c.Part("diff-settings"), label, icons.Settings, t.settingsOpen, false) + if clicked { + t.settingsOpen = !t.settingsOpen + } + ui.Menu(c.Part("diff-settings-menu"), gear, &t.settingsOpen, func(menu *ui.MenuItems) { + menu.WidthRange(180, 260) + if menu.Item(i18n.Shared(lang, "desktop.diff.keepWhitespace")).Checked(!t.ignoreSpace).Disabled(t.saved != nil).Clicked() { + c.After(func() { a.setDiffWhitespace(p, t, false) }) + } + if menu.Item(i18n.Shared(lang, "git.diff.ignoreWhitespace")).Checked(t.ignoreSpace).Disabled(t.saved != nil).Clicked() { + c.After(func() { a.setDiffWhitespace(p, t, true) }) + } + menu.Separator() + if menu.Item(i18n.Shared(lang, "native.collapseUnchangedFragments")).Checked(!t.whole).Clicked() { + c.After(func() { a.setDiffMode(p, t, t.split, !t.whole) }) + } + menu.Separator() + if menu.Item(i18n.Shared(lang, "native.closeDiff")).Clicked() { + c.After(func() { a.closeDiffTab(p, t.id) }) + } + }) }) } diff --git a/desktop/internal/workbench/editor_view.go b/desktop/internal/workbench/editor_view.go index a83fc01e4..e3e2001f3 100644 --- a/desktop/internal/workbench/editor_view.go +++ b/desktop/internal/workbench/editor_view.go @@ -68,7 +68,7 @@ func (a *App) editorGroup(c ui.C, id string) ui.Box { u.groups = append(u.groups, place) p := a.project doc := p.selectedDocument(id) - return ui.Column(c.Key(id)).Background(a.colors.Editor).Clip().Children(func() { + return ui.Column(c.Key(id)).Background(a.colors.Editor).Radius(workbenchPanelRadius).Clip().Children(func() { layout := a.tabLayout() strip := ui.Row(c).FillWidth().Shrink(0).Items(ui.Start).Gap(chromeGap).PadX(chromeGap).Background(a.colors.Panel).Clip() if layout != tabsRows { diff --git a/desktop/internal/workbench/filetree_menu.go b/desktop/internal/workbench/filetree_menu.go index 086c4bf40..1e02c291c 100644 --- a/desktop/internal/workbench/filetree_menu.go +++ b/desktop/internal/workbench/filetree_menu.go @@ -165,12 +165,14 @@ func (a *App) projectTreeMenu(c ui.C, p *project, n *treeNode, target ui.Box) { a.showRepositoryChangesOf(p, filepath.Join(p.root, filepath.FromSlash(n.path))) }) } - if menu.Item(i18n.Shared(language, "desktop.files.newFile")).Clicked() { - c.After(func() { a.openTreePrompt(p, treePromptNewFile, n) }) - } - if menu.Item(i18n.Shared(language, "desktop.files.newDirectory")).Clicked() { - c.After(func() { a.openTreePrompt(p, treePromptNewDirectory, n) }) - } + menu.Sub(i18n.Shared(language, "native.new"), func(sub *ui.MenuItems) { + if sub.Item(i18n.Shared(language, "desktop.files.newFile")).Clicked() { + c.After(func() { a.openTreePrompt(p, treePromptNewFile, n) }) + } + if sub.Item(i18n.Shared(language, "desktop.files.newDirectory")).Clicked() { + c.After(func() { a.openTreePrompt(p, treePromptNewDirectory, n) }) + } + }) } if !root { if menu.Item(i18n.Shared(language, "desktop.files.cut")).Clicked() { diff --git a/desktop/internal/workbench/gitlog_details_test.go b/desktop/internal/workbench/gitlog_details_test.go index d95c25b8c..4bb36e9cf 100644 --- a/desktop/internal/workbench/gitlog_details_test.go +++ b/desktop/internal/workbench/gitlog_details_test.go @@ -23,6 +23,8 @@ func TestLogCommitDetailsShowWholeAuthorLine(t *testing.T) { head := gitRun(t, dir, "rev-parse", "HEAD") h.app.delays = logDelays{} h.open(dir) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() p := h.app.project if p == nil || !p.git.isRepository() { t.Fatalf("the project is not a loaded repository: %+v", p) @@ -127,6 +129,8 @@ func TestLogCommitDetailsWrapReferenceChips(t *testing.T) { } h.app.delays = logDelays{} h.open(dir) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() p := h.app.project if p == nil || !p.git.isRepository() { t.Fatalf("the project is not a loaded repository: %+v", p) @@ -225,6 +229,8 @@ func TestLogCommitDetailsWrapMonospacedSubject(t *testing.T) { head := gitRun(t, dir, "rev-parse", "HEAD") h.app.delays = logDelays{} h.open(dir) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() p := h.app.project if p == nil || !p.git.isRepository() { t.Fatalf("the project is not a loaded repository: %+v", p) diff --git a/desktop/internal/workbench/gitlog_files_test.go b/desktop/internal/workbench/gitlog_files_test.go index 26d387eab..c4bcefd5e 100644 --- a/desktop/internal/workbench/gitlog_files_test.go +++ b/desktop/internal/workbench/gitlog_files_test.go @@ -41,6 +41,8 @@ func (h *harness) openLogFilesWith(t *testing.T, names ...string) (*project, str head := gitRun(t, dir, "rev-parse", "HEAD") h.app.delays = logDelays{} h.open(dir) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() p := h.app.project if p == nil || !p.git.isRepository() { t.Fatalf("the project is not a loaded repository: %+v", p) diff --git a/desktop/internal/workbench/gitlog_page_test.go b/desktop/internal/workbench/gitlog_page_test.go index c58284d41..22f8d20d1 100644 --- a/desktop/internal/workbench/gitlog_page_test.go +++ b/desktop/internal/workbench/gitlog_page_test.go @@ -18,7 +18,7 @@ func (h *harness) openLog(t *testing.T, dir string) *project { } h.app.layout.gitTab = gitTabLog h.settle() - if !p.log.started { + if !h.app.chrome.isOpen(catalogIndex[itemGit]) { h.click(center(h.button(itemGit))) h.settle() } diff --git a/desktop/internal/workbench/gitsync_test.go b/desktop/internal/workbench/gitsync_test.go index 21763b9a8..bfaa8b005 100644 --- a/desktop/internal/workbench/gitsync_test.go +++ b/desktop/internal/workbench/gitsync_test.go @@ -201,6 +201,8 @@ func TestSyncProgressKeepsOnlyTheLatestPhaseOfTheRunningOperation(t *testing.T) func TestLogUpdateSelectedBranchFastForwardsWithoutCheckingItOut(t *testing.T) { h, p, dir, other := syncFixture(t) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() gitRun(t, dir, "branch", "--track", "side", "origin/main") publishCommit(t, other, "side.txt") h.app.loadLog(p) @@ -228,6 +230,8 @@ func TestLogUpdateSelectedBranchFastForwardsWithoutCheckingItOut(t *testing.T) { func TestLogNewAndDeleteBranchFromTheBranchesPane(t *testing.T) { h, p, dir, _ := syncFixture(t) + h.app.reduceChrome(core.SetChromeOpen(itemGit, true)) + h.settleChain() h.clickNamed(h.app.text.Git.NewBranch) h.settle() b := &h.app.branch diff --git a/desktop/internal/workbench/root.go b/desktop/internal/workbench/root.go index c7570af8c..f4c18dd9f 100644 --- a/desktop/internal/workbench/root.go +++ b/desktop/internal/workbench/root.go @@ -157,15 +157,15 @@ func (a *App) workbench(c ui.C) { // the stripes and the status bar are declared with the same state as the panels. hidden := slotCount root := c.Part("workbench") - ui.Column(root).Fill(). + ui.Column(root).Fill().Draw(func(d *ui.DrawContext) { + // Draw before the children: absolute children are painted above the islands. + a.paintProjectGradient(d.Canvas, d.Rect) + }). HitWith(ui.ID(root), a.text.App.Name, gfx.HitOptions{SkipFocus: true}). DropTarget(&a.dnd.tool, a.toolAccept, a.toolDrop). DropTarget(&a.dnd.tab, a.tabAccept, a.tabDrop). Children(func() { - // The project's color lies under the chrome, which leaves its own fill out. - ui.Row(c).Absolute().Left(0).Top(0).Fill().Draw(func(d *ui.DrawContext) { - a.paintProjectGradient(d.Canvas, d.Rect) - }) + a.titleBar(c) ui.Row(c).Fill().Children(func() { a.stripe(c, true) diff --git a/desktop/internal/workbench/shell.go b/desktop/internal/workbench/shell.go index 2c090a556..7c9e4a00d 100644 --- a/desktop/internal/workbench/shell.go +++ b/desktop/internal/workbench/shell.go @@ -17,6 +17,12 @@ const ( rowHeight = 24 ) +// The native baseline uses 10-point islands and 41-point plain tool headers. +const ( + workbenchPanelRadius = 10 + toolTitleHeight = 41 +) + // Sizes only the shell needs. const ( // shellSplitter is the thickness Viva gives a Splitter; the panels beside it are diff --git a/desktop/internal/workbench/shell_view.go b/desktop/internal/workbench/shell_view.go index 9315f9047..7816af3dc 100644 --- a/desktop/internal/workbench/shell_view.go +++ b/desktop/internal/workbench/shell_view.go @@ -84,11 +84,14 @@ func (a *App) shellPanel(c ui.C, s slot, index int, hidden *slot) ui.Box { tool := a.chrome.catalog[index] p := a.project tabbed := tool.id == itemGit - header := float32(panelHeaderHeight) + header := float32(toolTitleHeight) + if tabbed || tool.id == itemTerminal || tool.id == itemCommit { + header = panelHeaderHeight + } if tool.id == itemFind && p != nil { header = 39 } - return ui.Column(c.KeyInt(int(s))).Background(a.colors.Panel).Clip().Children(func() { + return ui.Column(c.KeyInt(int(s))).Background(a.colors.Panel).Radius(workbenchPanelRadius).Clip().Children(func() { // The header is a target of its own so a right-click on it offers to hide the tool // window, as the earlier client's tool window headers did. hk := c.KeyInt(int(s)).Part("header") @@ -101,7 +104,7 @@ func (a *App) shellPanel(c ui.C, s slot, index int, hidden *slot) ui.Box { if tool.id == itemCommit && p != nil { a.commitTabs(c) } else { - ui.Text(c, tool.label(a.text)).Color(a.colors.Text).Weight(gfx.WeightMedium).Ellipsis() + ui.Text(c, tool.label(a.text)).Color(a.colors.Text).Weight(gfx.WeightBold).Ellipsis() } if tabbed { a.gitTabs(c) @@ -270,7 +273,7 @@ func (a *App) toolTabsWith(c ui.C, selected int, labels []string, choose func(in }). Pressed(ui.Look{}.Background(a.colors.Active)) if on { - chip = chip.Background(a.colors.Active) + chip = chip.Background(a.colors.TabIdle).Stroke(1, a.colors.TabIdleBorder).Radius(6) } else { chip = chip.Hover(ui.Look{}.Background(a.colors.Hover)) } @@ -300,12 +303,10 @@ func (a *App) editorArea(c ui.C) { return } hints := [...]struct{ label, keys string }{ - {a.text.Editor.SearchEverywhere, "⇧⇧"}, - {a.text.Editor.GoToFile, "⇧⌘O"}, + {a.text.Editor.SearchEverywhere, "⇧⇧ ⇧⌘O"}, {a.text.Editor.FindInFiles, "⇧⌘F"}, - {a.text.Editor.RecentFiles, "⌘E"}, } - editor := ui.Column(c).Fill().Clip().Center().Pad(shellContentPad).Background(a.colors.Editor).Children(func() { + editor := ui.Column(c).Fill().Clip().Center().Pad(shellContentPad).Background(a.colors.Editor).Radius(workbenchPanelRadius).Children(func() { ui.Column(c).FillWidth().MaxWidth(shellHintWidth).Children(func() { // Labels end and shortcuts start at one line down the middle, so the two read as // columns however long the words are. @@ -315,9 +316,6 @@ func (a *App) editorArea(c ui.C) { ui.Text(c, hint.keys).Color(a.colors.Text).FillWidth() }) } - ui.Row(c).Height(rowHeight).Items(ui.Center).Children(func() { - ui.Text(c, a.text.Editor.DropFiles).Color(a.colors.Subtle).TextCenter().Ellipsis().FillWidth() - }) }) }) a.term.emptyHook(editor) diff --git a/desktop/internal/workbench/tab_menu.go b/desktop/internal/workbench/tab_menu.go index 44615aa4e..258fd6c92 100644 --- a/desktop/internal/workbench/tab_menu.go +++ b/desktop/internal/workbench/tab_menu.go @@ -75,12 +75,14 @@ func (a *App) tabMenuItems(c ui.C, group string, t shownTab, tabs []shownTab) fu } menu.Separator() path := d.path - if menu.Item(i18n.Shared(language, "native.copyPath")).Clicked() { - c.After(func() { a.copyTreePath(path, false) }) - } - if menu.Item(i18n.Shared(language, "native.copyRelativePath")).Clicked() { - c.After(func() { a.copyTreePath(path, true) }) - } + menu.Sub(i18n.Shared(language, "native.copyPathReference"), func(sub *ui.MenuItems) { + if sub.Item(i18n.Shared(language, "native.copyPath")).Clicked() { + c.After(func() { a.copyTreePath(path, false) }) + } + if sub.Item(i18n.Shared(language, "native.copyRelativePath")).Clicked() { + c.After(func() { a.copyTreePath(path, true) }) + } + }) if menu.Item(revealLabel(language)).Clicked() { c.After(func() { a.performed("editor.tabs.reveal", surfaceMenu) diff --git a/desktop/internal/workbench/tabstrip.go b/desktop/internal/workbench/tabstrip.go index 9a54b46c5..cadc824bb 100644 --- a/desktop/internal/workbench/tabstrip.go +++ b/desktop/internal/workbench/tabstrip.go @@ -410,7 +410,11 @@ func (a *App) stripTab(c ui.C, place *groupPlace, t shownTab, current string, i label, icon := a.tabLabel(t), a.tabIcon(t) chip = chip.HitWith(id, label, options).Pressed(ui.Look{}.Background(a.colors.Active)) if selected { - chip = chip.Background(a.colors.Active) + fill, border := a.colors.TabIdle, a.colors.TabIdleBorder + if !a.windowInactive && a.project.terms.bench.FocusedGroup == place.id { + fill, border = a.colors.Tab, a.colors.TabBorder + } + chip = chip.Background(fill).Stroke(1, border).Radius(6) } else { chip = chip.Hover(ui.Look{}.Background(a.colors.Hover)) } diff --git a/desktop/internal/workbench/theme.go b/desktop/internal/workbench/theme.go index 1c9253423..1b2b74cae 100644 --- a/desktop/internal/workbench/theme.go +++ b/desktop/internal/workbench/theme.go @@ -36,6 +36,8 @@ type palette struct { // SelectionIdle is that same row while another window has the keyboard: the row stays // marked, greyed, as IDEA dims the selection of an unfocused tree. SelectionIdle gfx.Color + // Tab colors follow the original LitheToolWindowTabStyle, including window focus. + Tab, TabBorder, TabIdle, TabIdleBorder gfx.Color // Text is ordinary text; Subtle is secondary text and inactive icons. Text gfx.Color Subtle gfx.Color @@ -101,8 +103,9 @@ func (a *App) applySelectionFocus() { func lightLook() (gfx.Theme, palette) { accent := gfx.RGB24(0x3574F0) p := palette{ - Chrome: gfx.RGB24(0xF7F8FA), - Panel: gfx.RGB24(0xF7F8FA), + Tab: gfx.RGB24(0xE3EBFE), TabBorder: gfx.RGB24(0xA7C5FF), TabIdle: gfx.RGB24(0xE9EAEE), TabIdleBorder: gfx.RGB24(0xD1D3D9), + Chrome: gfx.RGB24(0xE8EBEF), + Panel: gfx.RGB24(0xFFFFFF), Editor: gfx.RGB24(0xFFFFFF), Line: gfx.RGB24(0xEBECF0), Hover: gfx.RGB24(0xEBECF0), @@ -129,6 +132,7 @@ func lightLook() (gfx.Theme, palette) { theme := gfx.LightTheme() theme.Background = p.Editor theme.Foreground = p.Text + theme.Card = p.Panel theme.CardForeground = p.Text theme.PopoverForeground = p.Text theme.MutedForeground = p.Subtle @@ -142,6 +146,7 @@ func lightLook() (gfx.Theme, palette) { theme.Success = p.Success theme.Metrics = theme.Metrics.WithDensity(gfx.DensityCompact) theme.Metrics.FontSizeS = 12 + theme.Transition.Duration = 0 // Hover tips keep IDEA's own surface: a four point corner, a one point border and the // two theme colors, instead of the inverted body the shared default draws. theme.Tooltip = gfx.TooltipStyle{ @@ -155,10 +160,11 @@ func lightLook() (gfx.Theme, palette) { func darkLook() (gfx.Theme, palette) { accent := gfx.RGB24(0x3574F0) p := palette{ - Chrome: gfx.RGB24(0x2B2D30), - Panel: gfx.RGB24(0x2B2D30), - Editor: gfx.RGB24(0x1E1F22), - Line: gfx.RGB24(0x1E1F22), + Tab: gfx.RGB24(0x233558), TabBorder: gfx.RGB24(0x2E4D89), TabIdle: gfx.RGB24(0x26282C), TabIdleBorder: gfx.RGB24(0x40434A), + Chrome: gfx.RGB24(0x28292C), + Panel: gfx.RGB24(0x191A1C), + Editor: gfx.RGB24(0x191A1C), + Line: gfx.RGB24(0x2E3036), Hover: gfx.RGB24(0x393B40), Active: gfx.RGB24(0x43454A), Selection: gfx.RGB24(0x2E436E), @@ -186,7 +192,7 @@ func darkLook() (gfx.Theme, palette) { theme.Foreground = p.Text theme.Card = p.Panel theme.CardForeground = p.Text - theme.Popover = gfx.RGB24(0x2B2D30) + theme.Popover = gfx.RGB24(0x28292C) theme.PopoverForeground = p.Text theme.MutedForeground = p.Subtle theme.Border = gfx.RGB24(0x393B40) @@ -199,6 +205,7 @@ func darkLook() (gfx.Theme, palette) { theme.Success = p.Success theme.Metrics = theme.Metrics.WithDensity(gfx.DensityCompact) theme.Metrics.FontSizeS = 12 + theme.Transition.Duration = 0 theme.Tooltip = gfx.TooltipStyle{ Radius: 4, PadX: 12, PadY: 8, FontSize: 13, Background: gfx.RGB24(0x33353B), Text: gfx.RGB24(0xD1D3D9), diff --git a/desktop/internal/workbench/ui_baseline_test.go b/desktop/internal/workbench/ui_baseline_test.go new file mode 100644 index 000000000..5b98188bb --- /dev/null +++ b/desktop/internal/workbench/ui_baseline_test.go @@ -0,0 +1,46 @@ +package workbench + +import ( + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/vivaui/viva/gfx" +) + +// The screenshot archive was captured from a824bd5c. These are the surfaces of +// that client's Lithe palette, rather than Viva's default IDE theme. +func TestBaselineWorkbenchSurfaces(t *testing.T) { + for _, dark := range []bool{false, true} { + theme, colors := lightLook() + surface, chrome := gfx.RGB24(0xffffff), gfx.RGB24(0xe8ebef) + if dark { + theme, colors = darkLook() + surface, chrome = gfx.RGB24(0x191a1c), gfx.RGB24(0x28292c) + } + if colors.Panel != surface || colors.Editor != surface || colors.Chrome != chrome { + t.Fatalf("dark=%v: tool windows and editor must share the baseline surface; got %+v", dark, colors) + } + if theme.Transition.Duration != 0 { + t.Fatal("product menus must open without a transition") + } + } +} + +func TestBaselineWelcomeEmptyStateHasVisualHierarchy(t *testing.T) { + h := newHarness(t, &testKernel{Client: core.NewClient()}) + var title, hint gfx.Rect + for _, text := range h.driver.Snapshot().Labels { + if text.Value == h.app.text.Welcome.EmptyTitle { + title = text.Bounds + } + if text.Value == h.app.text.Welcome.EmptyHint { + hint = text.Bounds + } + } + if title.Width == 0 || hint.Width == 0 || hint.Y <= title.Y { + t.Fatalf("empty state title=%v hint=%v", title, hint) + } + if h.app.text.Welcome.EmptyTitle != "没有最近项目" { + t.Fatalf("empty state did not use the original shared label: %q", h.app.text.Welcome.EmptyTitle) + } +} diff --git a/desktop/internal/workbench/visual_capture_test.go b/desktop/internal/workbench/visual_capture_test.go new file mode 100644 index 000000000..0a127bce8 --- /dev/null +++ b/desktop/internal/workbench/visual_capture_test.go @@ -0,0 +1,310 @@ +//go:build vivahead && lithevisual + +package workbench + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/1lck/Lithe-IDEA/desktop/internal/localshell" + "github.com/1lck/Lithe-IDEA/desktop/internal/store" + "github.com/vivaui/viva/app" + "github.com/vivaui/viva/headless" +) + +// TestVisualWorkbench uses the archive's repository in a private temporary checkout. +// No installed plugins, user settings, native windows or network services are used. +func TestVisualWorkbench(t *testing.T) { + out, reference := os.Getenv("LITHE_VISUAL_OUTPUT_DIR"), os.Getenv("LITHE_VISUAL_BASELINE_DIR") + if out == "" || reference == "" { + t.Skip("set LITHE_VISUAL_OUTPUT_DIR and LITHE_VISUAL_BASELINE_DIR") + } + if err := os.MkdirAll(out, 0755); err != nil { + t.Fatal(err) + } + for _, theme := range []string{"dark", "light"} { + t.Run(theme, func(t *testing.T) { + requireTestGit(t) + root := filepath.Join(t.TempDir(), "BaselineDemo") + gitRun(t, t.TempDir(), "clone", filepath.Join(reference, "fixture-history.bundle"), root) + fixtures := os.DirFS(filepath.Join(reference, "fixture-working-tree")) + err := fs.WalkDir(fixtures, ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return os.MkdirAll(filepath.Join(root, path), 0700) + } + data, err := fs.ReadFile(fixtures, path) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(root, path), data, 0600) + }) + if err != nil { + t.Fatal(err) + } + gitRun(t, root, "add", "notes.txt") + + scenes := []struct { + id, tool string + docs int + }{ + {"01-welcome-empty", "", -1}, {"02-new-empty-project", "", -1}, {"03-welcome-more-menu", "", -1}, {"04-clone-repository", "", -1}, + {"40-workbench-empty", "", 0}, {"41-editor-project", "", 1}, {"42-editor-tabs", "", 2}, + {"50-git-log", itemGit, 2}, {"52-git-worktrees", itemGit, 2}, {"53-git-console", itemGit, 2}, + {"54-git-commit", itemCommit, 2}, {"61-problems", itemProblems, 2}, {"62-run-empty", itemRun, 2}, + {"63-tests-empty", itemTests, 2}, {"64-debug-empty", itemDebug, 2}, {"55-branch-popup", "", 2}, + {"73-project-switcher", "", 2}, {"68-run-config-picker", "", 2}, {"65-project-search", itemFind, 2}, + {"66-project-replace", itemFind, 2}, {"67-search-everywhere", "", 2}, {"m19-goto-line-dialog", "", 2}, + {"56-diff-split", itemCommit, 2}, {"57-diff-unified", itemCommit, 2}, {"m47-diff-settings", itemCommit, 2}, + {"69-encoding-menu", "", 2}, {"71-workbench-gear-menu", "", 2}, + {"51-git-user-filter", itemGit, 2}, {"m07-git-tool-actions", itemGit, 2}, {"m09-git-date-filter", itemGit, 2}, + {"m10-git-path-filter", itemGit, 2}, {"m11-git-branch-filter", itemGit, 2}, + {"m02-directory-new-submenu", "", 2}, {"m13-tool-window-move-submenu", "", 2}, {"m17-editor-tab-copy-path", "", 2}, {"m50-git-tracking-branch-submenu", itemGit, 2}, {"m44-git-change-context", itemCommit, 2}, + {"m06-branch-popup", "", 2}, {"m20-language-service-menu", "", 2}, + {"m01-directory-context", "", 2}, {"m04-project-root-context", "", 2}, + {"m12-tool-window-context", "", 2}, {"m16-editor-tab-single-context", "", 1}, + {"m18-editor-tab-multiple-context", "", 2}, {"m22-rename-dialog", "", 2}, {"m23-new-file-dialog", "", 2}, + {"m27-editor-content-context", "", 2}, {"m48-git-local-branch-context", itemGit, 2}, + {"m49-git-commit-context", itemGit, 2}, {"60-terminal", itemTerminal, 2}, {"m14-terminal-actions", itemTerminal, 2}, {"m15-terminal-shell-menu", itemTerminal, 2}, + } + for _, scene := range scenes { + t.Run(scene.id, func(t *testing.T) { + prefs := store.At(t.TempDir()) + if err := prefs.Save(settingsFile, map[string]string{"theme": theme}); err != nil { + t.Fatal(err) + } + var session *headless.Session + h := newHarnessWithDriver(t, Options{Kernel: gitKernel{core.NewClient()}, Store: prefs, Languages: []string{"zh-Hans"}, Version: "0.1.0"}, func(config app.Config) (*app.Driver, func(), error) { + config.Width, config.Height = 1440, 871 + if scene.docs < 0 { + config.Width, config.Height = 800, 682 + config.MinWidth = 800 + } + var err error + session, err = headless.Open(config, nil, headless.Options{}) + if err != nil { + return nil, nil, err + } + return session.Driver(), session.Close, nil + }) + var p *project + if scene.docs >= 0 { + p = h.openTree(t, root) + for s, index := range h.app.chrome.open { + if index >= 0 && h.app.chrome.catalog[index].id != itemProject { + h.app.hidePanel(slot(s)) + } + } + h.settleChain() + for _, file := range []string{"src/main.swift", "src/config.json"}[:scene.docs] { + h.app.openFile(p, filepath.Join(root, file)) + h.settleChain() + } + if scene.tool != "" { + h.click(center(h.button(scene.tool))) + h.settleChain() + } + } + switch scene.id { + case "56-diff-split", "57-diff-unified", "m47-diff-settings": + diff := openDiffTab(t, h, p, "notes.txt") + h.app.setDiffMode(p, diff, scene.id != "57-diff-unified", diff.whole) + h.settleChain() + if scene.id == "m47-diff-settings" { + h.clickNamed(h.shared("native.diffSettings")) + } + case "m06-branch-popup": + h.app.openBranches() + case "m20-language-service-menu": + h.click(center(h.button(itemLSP))) + case "51-git-user-filter": + h.clickNamed(h.app.text.Git.FilterUser) + case "m07-git-tool-actions": + h.clickNamed(h.app.text.Git.Actions) + case "m09-git-date-filter": + h.clickNamed(h.app.text.Git.FilterDate) + case "m10-git-path-filter": + h.clickNamed(h.app.text.Git.FilterPaths) + case "m11-git-branch-filter": + h.clickNamed(h.app.text.Git.FilterBranch) + case "m02-directory-new-submenu": + h.rightClickNamed("src") + h.clickNamed(h.shared("native.new")) + case "m13-tool-window-move-submenu": + h.rightClickAt(h.button(itemProject)) + h.clickNamed(h.shared("desktop.tools.moveTo")) + case "m17-editor-tab-copy-path": + h.rightClickAt(h.tabHit("config.json")) + h.clickNamed(h.shared("native.copyPathReference")) + case "m50-git-tracking-branch-submenu": + h.logRefMenu("main") + h.clickNamed(h.shared("native.trackingBranch")) + case "m44-git-change-context": + h.rightClickNamed("notes.txt") + case "m01-directory-context": + h.rightClickNamed("src") + case "m04-project-root-context": + h.rightClickNamed("BaselineDemo") + case "m12-tool-window-context": + h.rightClickAt(h.button(itemProject)) + case "m16-editor-tab-single-context": + h.rightClickAt(h.tabHit("main.swift")) + case "m18-editor-tab-multiple-context": + h.rightClickAt(h.tabHit("config.json")) + case "m22-rename-dialog": + h.rightClickNamed("README.txt") + h.clickNamed(h.shared("desktop.files.rename")) + case "m23-new-file-dialog": + h.rightClickNamed("BaselineDemo") + h.clickNamed(h.shared("native.new")) + h.clickNamed(h.shared("desktop.files.newFile")) + case "m27-editor-content-context": + d := p.selectedDocument(p.docs.group) + h.rightClickAt(h.topHit(d.hit)) + case "m48-git-local-branch-context": + h.logRefMenu("main") + case "m49-git-commit-context": + h.rightClickNamed("Document visual baseline workflow") + case "60-terminal", "m14-terminal-actions", "m15-terminal-shell-menu": + term := busyTerminal(t, h, "printf 'Lithe terminal visual baseline\\n'; read line") + h.await("terminal output", func() bool { return strings.Contains(term.view.RetainedText(4096), "visual baseline") }) + h.settleChain() + if scene.id == "m14-terminal-actions" { + h.rightClickAt(h.topHit(term.hit)) + } + if scene.id == "m15-terminal-shell-menu" { + h.app.listShells = func() []localshell.Choice { + return []localshell.Choice{{Name: "zsh", Path: "/bin/zsh", Default: true}, {Name: "bash", Path: "/bin/bash"}} + } + h.clickNamed(h.app.text.Terminal.ShellMenu) + } + case "02-new-empty-project": + h.openNewProjectSheet() + case "03-welcome-more-menu": + h.clickNamed(i18n.Shared(h.app.language(), "desktop.clone.more")) + case "04-clone-repository": + h.clickNamed(h.shared("desktop.clone.more")) + h.clickNamed(h.shared("desktop.clone.title")) + case "52-git-worktrees": + h.clickNamed(h.app.text.Git.Worktrees) + case "53-git-console": + h.clickNamed(h.app.text.Git.Console) + case "55-branch-popup": + h.app.openBranches() + case "73-project-switcher": + h.clickNamed(h.app.text.Title.ProjectMenu) + case "68-run-config-picker": + h.click(center(h.button(itemRunConfig))) + case "65-project-search": + h.app.openFindInFiles(p, false) + h.app.searchView.query.SetText("greet") + case "66-project-replace": + h.app.openFindInFiles(p, true) + h.app.searchView.query.SetText("greet") + case "67-search-everywhere": + h.app.everywhereView.delay = 0 + h.app.openEverywhere(p) + h.app.everywhereView.query.SetText("main") + case "m19-goto-line-dialog": + h.click(center(h.button(itemCaret))) + case "69-encoding-menu": + h.click(center(h.button(itemEncoding))) + case "71-workbench-gear-menu": + h.click(center(h.button(itemTitleSettings))) + } + h.settleChain() + if scene.id == "65-project-search" || scene.id == "66-project-replace" { + if err := h.driver.Click(center(h.hit(searchQueryID)).X, center(h.hit(searchQueryID)).Y); err != nil { + t.Fatal(err) + } + if _, err := h.driver.ShortcutChord(primaryModifier(), 'a'); err != nil { + t.Fatal(err) + } + if err := h.driver.Type("greet"); err != nil { + t.Fatal(err) + } + h.await("search result rows", func() bool { return !p.search.loading && len(p.search.matches) > 0 }) + } + if scene.id == "67-search-everywhere" { + h.await("global search result rows", func() bool { return !p.everywhere.loading && len(p.everywhere.matches) > 0 }) + } + // Every transient scene must prove the requested surface was painted. + // Mutating state without input can leave the driver's last frame unchanged. + visible := "" + switch scene.id { + case "m19-goto-line-dialog": + visible = h.shared("desktop.navigation.title") + case "m22-rename-dialog": + visible = h.shared("desktop.files.renameTitle") + case "m23-new-file-dialog": + visible = h.shared("desktop.files.newFileTitle") + case "69-encoding-menu": + visible = h.shared("desktop.encoding.reopen") + case "71-workbench-gear-menu": + visible = h.app.text.Title.Preferences + case "73-project-switcher": + visible = h.app.text.Title.OpenProjects + case "68-run-config-picker": + visible = h.shared("native.editConfiguration") + case "m07-git-tool-actions": + visible = h.app.text.Git.FetchAll + case "m09-git-date-filter": + visible = h.app.text.Git.Last7Days + case "m10-git-path-filter": + visible = h.app.text.Git.FilterPaths + case "m11-git-branch-filter": + visible = h.app.text.Git.AllReferences + case "m15-terminal-shell-menu": + visible = h.app.text.Terminal.Settings + case "04-clone-repository": + visible = h.shared("desktop.clone.title") + } + if visible != "" && !h.shows(visible) { + if err := session.Screenshot(filepath.Join(out, "failed-"+scene.id+"-"+theme+".png")); err != nil { + t.Logf("capture failed scene: %v", err) + } + t.Fatalf("visual scene %s did not paint %q; shell menu state: %+v", scene.id, visible, h.app.term.shells) + } + if err := session.Screenshot(filepath.Join(out, scene.id+"-"+theme+".png")); err != nil { + t.Fatal(err) + } + }) + } + }) + } +} + +// The project's gradient belongs behind the solid editor/tool islands. An absolute +// background child is painted after normal siblings and incorrectly tints them. +func TestVisualWorkbenchGradientStaysBehindPanels(t *testing.T) { + var session *headless.Session + prefs := store.At(t.TempDir()) + if err := prefs.Save(settingsFile, map[string]string{"theme": "dark"}); err != nil { + t.Fatal(err) + } + h := newHarnessWithDriver(t, Options{Kernel: &testKernel{Client: core.NewClient()}, Store: prefs, Languages: []string{"en-US"}}, func(config app.Config) (*app.Driver, func(), error) { + var err error + session, err = headless.Open(config, nil, headless.Options{Scale: 1}) + if err != nil { + return nil, nil, err + } + return session.Driver(), session.Close, nil + }) + h.openTree(t, t.TempDir()) + h.settleChain() + picture, err := session.Image() + if err != nil { + t.Fatal(err) + } + got := picture.RGBAAt(150, 280) + if got.R != 25 || got.G != 26 || got.B != 28 { + t.Fatalf("project gradient covered the opaque tool panel: %v, want #191a1c", got) + } +} diff --git a/desktop/internal/workbench/welcome_view.go b/desktop/internal/workbench/welcome_view.go index 9d7da19ac..919fea3ad 100644 --- a/desktop/internal/workbench/welcome_view.go +++ b/desktop/internal/workbench/welcome_view.go @@ -96,7 +96,7 @@ func (a *App) welcomeContent(c ui.C) { ui.TextField(c, &w.search, a.text.Welcome.SearchLabel, a.text.Welcome.SearchPlaceholder). Icon(icons.Search).Width(214).MinWidth(0).Shrink(1) ui.Spacer(c) - if ui.Button(c, a.text.Welcome.NewProject).MinWidth(72).Clicked() { + if ui.Button(c, a.text.Welcome.NewProject).Outline().MinWidth(72).Clicked() { c.After(func() { a.openNewProject(surfaceButton) }) } if ui.Button(c, a.text.Welcome.Open).Outline().MinWidth(72).Clicked() { @@ -125,8 +125,9 @@ func (a *App) welcomeProjects(c ui.C) { w := &a.welcome if len(a.recent) == 0 { w.menuPath, w.menuOpen = "", false - ui.Column(c).Grow(1).Center().Gap(4).Children(func() { - ui.Text(c, a.text.Welcome.EmptyTitle).Bold() + ui.Column(c).Grow(1).Center().Gap(10).Children(func() { + ui.Icon(c, icons.FolderOpen).Size(40, 40).Color(a.colors.Subtle) + ui.Text(c, a.text.Welcome.EmptyTitle).FontSize(14).Weight(gfx.WeightMedium) ui.Text(c, a.text.Welcome.EmptyHint).Muted() }) return diff --git a/docs/assets/preview-ui-baseline/settings-en-dark.png b/docs/assets/preview-ui-baseline/settings-en-dark.png new file mode 100644 index 000000000..f7e0c8185 Binary files /dev/null and b/docs/assets/preview-ui-baseline/settings-en-dark.png differ diff --git a/docs/assets/preview-ui-baseline/settings-en-light.png b/docs/assets/preview-ui-baseline/settings-en-light.png new file mode 100644 index 000000000..b404f6b7c Binary files /dev/null and b/docs/assets/preview-ui-baseline/settings-en-light.png differ diff --git a/docs/assets/preview-ui-baseline/settings-zh-dark.png b/docs/assets/preview-ui-baseline/settings-zh-dark.png new file mode 100644 index 000000000..98fbb527d Binary files /dev/null and b/docs/assets/preview-ui-baseline/settings-zh-dark.png differ diff --git a/docs/assets/preview-ui-baseline/settings-zh-light.png b/docs/assets/preview-ui-baseline/settings-zh-light.png new file mode 100644 index 000000000..bc81062ac Binary files /dev/null and b/docs/assets/preview-ui-baseline/settings-zh-light.png differ diff --git a/docs/assets/preview-ui-baseline/workbench-zh-dark.png b/docs/assets/preview-ui-baseline/workbench-zh-dark.png new file mode 100644 index 000000000..b3f535e94 Binary files /dev/null and b/docs/assets/preview-ui-baseline/workbench-zh-dark.png differ diff --git a/docs/assets/preview-ui-baseline/workbench-zh-light.png b/docs/assets/preview-ui-baseline/workbench-zh-light.png new file mode 100644 index 000000000..b009ad6c0 Binary files /dev/null and b/docs/assets/preview-ui-baseline/workbench-zh-light.png differ diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 79b31aa2a..73d6a1cc0 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -1574,7 +1574,7 @@ "desktop.tools.background": "Background", "desktop.tools.dragHint": "Drag to move elsewhere", "desktop.tools.structure": "Structure", - "desktop.welcome.emptyHint": "Open a folder to get started", + "desktop.welcome.emptyHint": "Open a local folder to start working.", "desktop.welcome.emptyTitle": "No recent projects", "desktop.welcome.menuPin": "Pin to Top", "desktop.welcome.menuReveal": "Show in Finder", @@ -10998,7 +10998,9 @@ "desktop.title.currentFileNotRunnable": "The current file cannot run on its own. Choose a project run configuration from the run configuration menu.", "desktop.title.noRunConfigurations": "No run configurations", "desktop.title.notDebuggable": "{name} cannot be debugged: its plugin declares no debug adapter for this run.", - "desktop.title.runBusy": "The Run window is still running another operation. Wait for it to finish or stop it first." + "desktop.title.runBusy": "The Run window is still running another operation. Wait for it to finish or stop it first.", + "desktop.diff.keepWhitespace": "Do not ignore whitespace", + "desktop.tools.moveTo": "Move to" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 68c9f1022..d62dfeb28 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -1574,8 +1574,8 @@ "desktop.tools.background": "背景", "desktop.tools.dragHint": "可拖动到其他位置", "desktop.tools.structure": "结构", - "desktop.welcome.emptyHint": "打开一个文件夹开始工作", - "desktop.welcome.emptyTitle": "还没有最近项目", + "desktop.welcome.emptyHint": "打开本地文件夹开始工作。", + "desktop.welcome.emptyTitle": "没有最近项目", "desktop.welcome.menuPin": "固定到顶部", "desktop.welcome.menuReveal": "在访达中显示", "desktop.welcome.menuRevealOther": "在文件管理器中显示", @@ -10998,7 +10998,9 @@ "desktop.title.currentFileNotRunnable": "当前文件不能单独运行。请在运行配置菜单里选择一个项目运行配置。", "desktop.title.noRunConfigurations": "没有运行配置", "desktop.title.notDebuggable": "无法调试 {name}:它所属的插件没有为这个运行声明调试适配器。", - "desktop.title.runBusy": "运行窗口还在执行其他操作。请等它结束,或者先停止它。" + "desktop.title.runBusy": "运行窗口还在执行其他操作。请等它结束,或者先停止它。", + "desktop.diff.keepWhitespace": "不忽略空白", + "desktop.tools.moveTo": "移动到" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index b543bb023..02d208467 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -44773,7 +44773,8 @@ "id": "native.closeDiff", "description": "Native UI: Close diff", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "Close diff", @@ -45069,7 +45070,8 @@ "id": "native.closingATerminalTab", "description": "Native UI: Closing a terminal tab", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "Closing a terminal tab", @@ -47390,7 +47392,8 @@ "id": "native.copyPathReference", "description": "Native UI: Copy Path / Reference", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "Copy Path / Reference", @@ -50163,7 +50166,8 @@ "id": "native.diffSettings", "description": "Native UI: Diff settings", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "Diff settings", @@ -64917,7 +64921,8 @@ "id": "native.new", "description": "Native UI: New", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "New", @@ -91216,7 +91221,8 @@ "id": "native.whenQuittingLithe", "description": "Native UI: When quitting Lithe", "platforms": [ - "macos" + "macos", + "desktop" ], "macos": { "key": "When quitting Lithe", @@ -104837,6 +104843,20 @@ "key": "Other…", "arguments": [] } + }, + { + "id": "desktop.diff.keepWhitespace", + "description": "Diff settings menu: compare whitespace instead of ignoring it.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.tools.moveTo", + "description": "Parent submenu for moving a tool window to a docking zone.", + "platforms": [ + "desktop" + ] } ] } diff --git a/shared/platform-feature-matrix/features/application-settings.json b/shared/platform-feature-matrix/features/application-settings.json index c004cc7ff..f1d367988 100644 --- a/shared/platform-feature-matrix/features/application-settings.json +++ b/shared/platform-feature-matrix/features/application-settings.json @@ -14,7 +14,11 @@ "desktop/internal/workbench/settings.go", "desktop/internal/workbench/app_test.go", "desktop/internal/logwindow/window_test.go", - "desktop/internal/settingswindow/window_view.go" + "desktop/internal/settingswindow/window_view.go", + "desktop/internal/settingswindow/ui_baseline_test.go", + "desktop/internal/settingswindow/visual_capture_test.go", + "desktop/internal/settingswindow/terminal_view.go", + "desktop/internal/settingswindow/process_termination_view.go" ] }, "windows": { @@ -28,11 +32,15 @@ "desktop/internal/workbench/settings.go", "desktop/internal/workbench/app_test.go", "desktop/internal/logwindow/window_test.go", - "desktop/internal/settingswindow/window_view.go" + "desktop/internal/settingswindow/window_view.go", + "desktop/internal/settingswindow/ui_baseline_test.go", + "desktop/internal/settingswindow/visual_capture_test.go", + "desktop/internal/settingswindow/terminal_view.go", + "desktop/internal/settingswindow/process_termination_view.go" ] }, "owner": "Settings", - "notes": "Viva 设置窗口的每个侧栏页都已接入:外观、常规、更新、网络、编辑器、字体、快捷键、进程终止、插件、版本控制、构建与工具链、语言(插件贡献的动态页)、终端、AI 生成与 IDE API;外观与语言选择后立即应用。尚未完成原版界面像素级对照或跨平台实机验收。", + "notes": "Viva 设置窗口的每个侧栏页都已接入:外观、常规、更新、网络、编辑器、字体、快捷键、进程终止、插件、版本控制、构建与工具链、语言(插件贡献的动态页)、终端、AI 生成与 IDE API;外观与语言选择后立即应用。尚未完成原版界面像素级对照或跨平台实机验收。 本次按 main-macos-live-baseline(源提交 a824bd5c)校准 900×700 初始窗口、234pt 侧栏、24pt 导航行、深浅色页面/卡片/下拉层级和页边距;已检查 macOS 原生通用页及语言下拉,仍不代表所有设置页逐像素验收。 本轮全页对照将自动保存入口移回通用页,终端 Shell 改为共享下拉并保留检测/缺失/自动生效值,退出策略按原版合并同行,更新页拆开版本与操作。 编辑器标签的滚动、压缩和多行布局统一为共享下拉,选择多行时保留先前单行溢出偏好。TestVisualSettings 以离线事实采集深浅色页面和菜单;工具真实来源与未实现设置项仍按既有状态记录,不能把截图覆盖当原生验收。", "verification": "无头窗口覆盖说明悬停延迟、点击固定、Escape、键盘操作、窄窗口实际值,外观和语言即时应用、恢复默认、关闭不回滚,以及日志窗口重绘和跟随系统外观;分别生成常规设置截图、与原版 macOS 布局对照。完整设置页、真实窗口与真实配置读写仍待验收,Windows 尚未运行本次测试。运行 scripts/desktop/desktop-go.sh test -race ./internal/logwindow ./internal/settingswindow ./internal/workbench -run 'TestOpenWindowRepaintsWhenLanguageAndAppearanceChange|TestGeneral|TestSettingsChangesUpdateSharedStateAndPersist|TestSystemAppearanceChangesSharedStateWhenFollowingSystem' -count=3。", "legacy": { "revision": "5a54c41aa37a4e6e6b68918a4b05765b9dd07a36", diff --git a/shared/platform-feature-matrix/features/git-diff-review.json b/shared/platform-feature-matrix/features/git-diff-review.json index 3899139ae..9bd90d025 100644 --- a/shared/platform-feature-matrix/features/git-diff-review.json +++ b/shared/platform-feature-matrix/features/git-diff-review.json @@ -23,7 +23,8 @@ "desktop/internal/editorview/diffdecor_test.go", "desktop/internal/workbench/diff_find.go", "desktop/internal/workbench/difftab_edit.go", - "desktop/internal/workbench/difftab_whitespace.go" + "desktop/internal/workbench/difftab_whitespace.go", + "desktop/internal/workbench/visual_capture_test.go" ] }, "windows": { @@ -46,12 +47,13 @@ "desktop/internal/editorview/diffdecor_test.go", "desktop/internal/workbench/diff_find.go", "desktop/internal/workbench/difftab_edit.go", - "desktop/internal/workbench/difftab_whitespace.go" + "desktop/internal/workbench/difftab_whitespace.go", + "desktop/internal/workbench/visual_capture_test.go" ] }, "owner": "Git", "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 调整下方 Git Log 或终端高度时,上方 Diff 的全文测量、折叠和双栏规划不随尺寸重复计算;核对 GitCommitDiffReviewView、DiffReviewView 和共享 DiffPaneView 的新增/删除单栏、双栏、搜索/折叠/差异选择及收缩恢复行为。 长 Diff 双栏只创建可见区域附近的行;用 1,200 行夹具检查初始视口、滚动到末行和连续改变可见高度。 macOS 原生双栏 Diff 按 IDEA Islands/Darcula 配色显示新增绿、删除灰、修改蓝;JetBrains Mono Regular 13pt 与 22pt 行高;原版/新版行号分别固定在中央两侧,单侧增删后仍对应各自源文件行号。核对深浅外观、中央 1pt 分隔线、横向滚动和非等宽拖动后的连接带位置;单栏增删同步使用该配色。 双栏代码通过原生 NSTextView 连续选中,深色/浅色光标行号分别使用 A1A3AB/767A8A;整行修改背景按 IDEA 混合淡化,词内使用原修改色。分隔条允许先隐藏代码、再隐藏行号,双向完全收起后可恢复;窄窗口无负尺寸。1,200 行组件回归连续调整 60 次,检查文字存储不重建且固定容器排版不失效、透明定位/连接带不拦截代码点击。组件绘制耗时仅作诊断,完整应用的连续拖动视觉验收仍 pending。 历史提交 Diff 两行顶部显示紧凑工具栏与父/当前提交短哈希,左版本附中间省略的路径;隐藏补丁区块头,设置菜单保留词内高亮和关闭。切换单栏后版本标题上下排列,正文按旧/新顺序显示修改,左侧双源行号,仍可连续选中/复制。两侧滚动条按各自完整代码流标出增删改,点击跳至变更;纵向按视口三分之一锚点映射已有差异边界,匹配行连接带随滚动变平。检查真实原生 clip 偏移、标记颜色/点击、通知卸载和文字缓存不重建,保留共享折叠/区块操作。 历史提交页顶部按 Islands 独立工具栏层验证:40pt 内容、上 2pt/左右 6pt 留白与 6pt 圆角;版本标题四边 6pt 内边距、29pt 行高、1pt TEARLINE 底边;路径 13pt Regular/#73767C。布局选择由一个父外框及选中项边框绘制,检查未选项不重复描边、实际深浅像素和原生点击切换。 Windows 实现未改;完整工作区视觉与连续滚动性能验收保持 pending。 macOS 连续修改加多行插入、反向删除使用同一个左右源范围配对;纯插入/删除仍为绿/灰。检查行号、连接带、标记与整块正文一致,词内单侧变化使用增删色,滚动后空范围边界保留 1pt 线。 历史提交工具栏复用现有打开文件、当前提交内文件导航和 DiffCollapse,首尾/单文件箭头及删除文件打开入口禁用;双栏/单栏折叠、单区域展开和切换文件状态清理。版本标题使用真实布局高度,比较实际原生代码行转换到连接带的坐标,初始及滚动均无 29pt 偏移;中央宽度 24pt,源行号按真实字型测量并共享最大宽度,右数字不进入折叠空白区。Diff 错误条使用 14pt 宽标记(右 x=4、左 x=0),末行端点和 2pt 最小高度,滑块覆盖与点击定位保留。 Diff 正文和连接带不得绘制到工具栏或左右侧栏,平直连接带与行号区使用相同的排他行末端;差异导航不绘制蓝色竖线。只保留右侧纵向 thumb,左侧保留变更标记;真实窗口鼠标拖动右侧和底部横向 thumb 均改变代码偏移。嵌套工作区中将中央分隔条拖至两端后再次拉回,确认命中区域留在 Diff 内且外层面板不移动。多行调用重排按整段左右文本计算内部高亮,避免逐行配对造成绿色/灰色碎片;保留纯单侧内部变化颜色。 核对相邻等行数修改只高亮各行变化参数,正文保持淡色、中央保持原修改色;深浅主题下以 0/0.25/0.5/0.75pt 偏移验证相邻行背景无细缝。 中央连接带端点与行号填充采用相同设备像素覆盖范围;1×/2× 与小数滚动/拖动坐标下检查平直块跨中央区域无边框或接缝,保留曲线抗锯齿。 从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows Commit、Git Log 及统一 Diff 中确认完整上下文不折叠,没有 hidden lines;核对中央行号两侧留白与竖线、Commit 动作区比 Git Log 更宽且不遮挡数字或代码,每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,2026-10-17 起与文件列表及整仓库丢弃一致遵守「丢弃前确认」设置:开启时先确认再恢复该块且暂存区不变,关闭时直接执行,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 在 Windows 从提交面板点击未提交文件,确认差异标签标题为「提交: 文件名」(英文为 Commit: )并显示差异图标,悬停显示完整标题;在提交面板切换到另一个文件时同一标签标题随之更新;从编辑器 gutter 打开整文件差异时标题保持「未提交的更改」。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 macOS Commit/Shelf 标题栏创建补丁按钮使用共享工具栏按钮样式,悬停保持箭头;打开补丁弹窗检查共享浮层外观与主次按钮,列表选择、生成预览、关闭及应用确认流程保持不变。 macOS 标题栏仅显示创建补丁入口,不额外显示应用补丁按钮。创建和应用补丁表单使用 Commit 共享标题栏对话框、背景、留白与主次按钮;打开或取消原生文件选择器以及内部下拉菜单均不得清空表单。窗口打开时后台主窗口不能触发另一编辑,关闭后恢复交互;标题和内容继承同一应用语言,主窗口关闭仍释放面板与监听。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS Commit 与 Git Log/Stash 共用 RepositoryDiffView;核对搜索、空白比较、整文件及块级暂存/取消暂存/丢弃仍可用,新建/删除文件使用新版单栏,历史入口无写入动作。旧面板与无调用渲染组件删除;真实工作区视觉验收仍需执行。 字节保真回退:CRLF(autocrlf=false/true、.gitattributes eol=crlf)与无末尾换行文件在预置非空暂存的仓库中逐块回退,目标块恢复为暂存区对应内容、未涉及区域原样保留、index 内容与 staged diff 前后不变;filter 或 working-tree-encoding 属性不安全的文件继续整文件降级。 块级回退确认:「丢弃前确认」开启时,预览面与 Monaco 块箭头均先弹确认,取消不写、确认后按打开时目标执行;确认期间同文件快照刷新后确认返回时不写入并提示选中内容已变化,切换文件或仓库后确认返回时不写入且不在新目标显示提示;重复点击只保留一个确认;设置关闭时直接执行。 macOS GitModuleTests 验证从linked worktree打开Diff后暂存/取消暂存仍保留比较、回滚后关闭,调用根目录不变且历史cursor不被关闭;实机工作树整文件与块级动作、切换工作树/仓库及后台刷新仍需验证。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。 EditorTabOrderFeatureModelTests 验证同文件显式新标签与普通去重并存、关闭一个不影响另一个;VcsProviderAppModelTests 用可控基线请求覆盖成功、关闭标签、关闭工作区及焦点保持,确认任务和数据映射清空。真实插件新标签与分屏仍待目标产品验收。 Viva:运行 scripts/desktop/desktop-go.sh test ./internal/workbench -run TestDiffTab -count=1,覆盖查找两侧、忽略空白后差异数、无块计划时右侧编辑写入 document,以及有块计划时右侧只读。", - "notes": "Viva:Commit 面板的变更行右键/双击接入 Core 封装,对话框已实现;见 desktop/internal/workbench/gitreview*.go。未覆盖项见 PR 描述。 Diff 编辑区标签页(difftab*.go、diffpanes.go):并排为左右两个只读 Viva 编辑器,按文件语言做 TextMate 语法高亮;原版行号在中缝,改动之间用色带相连,块级勾选与回退放在中缝;折叠行带展开图标与横线;两侧同步滚动,差异跳转时两侧都把光标放到该处并居中;统一视图显示双行号。词内高亮、折叠行按上/下 20 行或全部展开、Diff 内查找(两侧、不区分大小写、循环跳转)与忽略空白已实现;右侧在工作副本且文件已打开、没有块计划时可编辑(未跟踪文件、忽略空白),编辑写入该文件的 document,保存后重读;有块计划(可逐块暂存/回退)时右侧保持只读,避免未保存编辑与磁盘上的块不一致。完整工作区视觉与连续滚动性能仍待实机验收。", + "notes": "Viva:Commit 面板的变更行右键/双击接入 Core 封装,对话框已实现;见 desktop/internal/workbench/gitreview*.go。未覆盖项见 PR 描述。 Diff 编辑区标签页(difftab*.go、diffpanes.go):并排为左右两个只读 Viva 编辑器,按文件语言做 TextMate 语法高亮;原版行号在中缝,改动之间用色带相连,块级勾选与回退放在中缝;折叠行带展开图标与横线;两侧同步滚动,差异跳转时两侧都把光标放到该处并居中;统一视图显示双行号。词内高亮、折叠行按上/下 20 行或全部展开、Diff 内查找(两侧、不区分大小写、循环跳转)与忽略空白已实现;右侧在工作副本且文件已打开、没有块计划时可编辑(未跟踪文件、忽略空白),编辑写入该文件的 document,保存后重读;有块计划(可逐块暂存/回退)时右侧保持只读,避免未保存编辑与磁盘上的块不一致。完整工作区视觉与连续滚动性能仍待实机验收。 Diff 设置菜单复用已有空白比较、折叠与关闭操作;TestDiffSettingsMenuUsesExistingWhitespaceAndCloseActions 验证状态切换和关闭释放,TestVisualWorkbench 采集深浅色并排/统一/菜单。未实现的旧版设置项未伪造入口。Diff 全文匹配下沉跟踪 #1847。", "legacy": { "revision": "5a54c41aa37a4e6e6b68918a4b05765b9dd07a36", "macos": { diff --git a/shared/platform-feature-matrix/features/onboarding-welcome.json b/shared/platform-feature-matrix/features/onboarding-welcome.json index 458179916..0ccb17068 100644 --- a/shared/platform-feature-matrix/features/onboarding-welcome.json +++ b/shared/platform-feature-matrix/features/onboarding-welcome.json @@ -18,7 +18,8 @@ "desktop/internal/gitmetadata/read_test.go", "desktop/internal/core/git_checkout.go", "desktop/internal/workbench/app_test.go", - "desktop/internal/i18n/welcome_messages.go" + "desktop/internal/i18n/welcome_messages.go", + "desktop/internal/workbench/ui_baseline_test.go" ] }, "windows": { @@ -36,7 +37,8 @@ "desktop/internal/gitmetadata/read_test.go", "desktop/internal/core/git_checkout.go", "desktop/internal/workbench/app_test.go", - "desktop/internal/i18n/welcome_messages.go" + "desktop/internal/i18n/welcome_messages.go", + "desktop/internal/workbench/ui_baseline_test.go" ] }, "owner": "Onboarding", @@ -66,5 +68,5 @@ "verificationStatus": "pending" } }, - "notes": "Viva 已有欢迎页、最近项目列表与搜索、打开项目和设置入口;最近项目支持右键、悬停或键盘菜单打开、定位和从列表移除,移除操作交给 Core 且不会删除目录。欢迎页词条和标题已接入共享 i18n;侧栏、搜索框、标题及徽标位置已结合当前 macOS 原版截图和无头截图调整,但完整像素对齐尚未完成。欢迎页在后台有界读取 Git 元数据,由 Core 解析普通仓库、worktree 和分离 HEAD;Windows 实机已运行真实 Core 桥接和欢迎页测试,并检查普通仓库分支的无头截图,但完整原生窗口工作流尚未验收。项目切换菜单仍会扫描完整 Git 状态,跟踪 #1808;欢迎页和项目菜单可通过 Core 克隆仓库,macOS 无头测试用本地仓库验证成功、失败、取消及目标目录保护;URL 自动建议名称、克隆弹窗原版视觉、远程认证及 Windows 原生流程仍待验证或实现。空项目创建已接入,插件生成器和完整首次启动引导仍待完成;legacy 是迁移前基线,不代表当前实现。" + "notes": "Viva 已有欢迎页、最近项目列表与搜索、打开项目和设置入口;最近项目支持右键、悬停或键盘菜单打开、定位和从列表移除,移除操作交给 Core 且不会删除目录。欢迎页词条和标题已接入共享 i18n;侧栏、搜索框、标题及徽标位置已结合当前 macOS 原版截图和无头截图调整,但完整像素对齐尚未完成。欢迎页在后台有界读取 Git 元数据,由 Core 解析普通仓库、worktree 和分离 HEAD;Windows 实机已运行真实 Core 桥接和欢迎页测试,并检查普通仓库分支的无头截图,但完整原生窗口工作流尚未验收。项目切换菜单仍会扫描完整 Git 状态,跟踪 #1808;欢迎页和项目菜单可通过 Core 克隆仓库,macOS 无头测试用本地仓库验证成功、失败、取消及目标目录保护;URL 自动建议名称、克隆弹窗原版视觉、远程认证及 Windows 原生流程仍待验证或实现。空项目创建已接入,插件生成器和完整首次启动引导仍待完成;legacy 是迁移前基线,不代表当前实现。 本次补齐欢迎空态图标和原版共享文案,恢复新建项目描边按钮;同步校准共享工作台深浅色面板与编辑器表面、10pt 圆角和普通工具窗口 41pt 粗体标题。完整截图清单逐项验收仍未完成。" } diff --git a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json index 2a6351a66..565322e21 100644 --- a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json +++ b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json @@ -8,7 +8,10 @@ "verificationStatus": "pending", "evidence": [ "desktop/internal/workbench/catalog.go", - "desktop/internal/workbench/dockdrag.go" + "desktop/internal/workbench/dockdrag.go", + "desktop/internal/workbench/visual_capture_test.go", + "desktop/internal/workbench/root.go", + "desktop/internal/workbench/tabstrip.go" ] }, "windows": { @@ -16,7 +19,10 @@ "verificationStatus": "pending", "evidence": [ "desktop/internal/workbench/catalog.go", - "desktop/internal/workbench/dockdrag.go" + "desktop/internal/workbench/dockdrag.go", + "desktop/internal/workbench/visual_capture_test.go", + "desktop/internal/workbench/root.go", + "desktop/internal/workbench/tabstrip.go" ] }, "owner": "Workbench", @@ -50,5 +56,5 @@ "verificationStatus": "pending" } }, - "notes": "Viva:catalog.go 定义六个停靠区,dockdrag.go 处理拖动落点与遮罩,布局经 loadChrome 保存;未核到组内排序、右键移动和多工作区布局恢复的完整实现。" + "notes": "Viva:catalog.go 定义六个停靠区,dockdrag.go 处理拖动落点与遮罩,布局经 loadChrome 保存;未核到组内排序、右键移动和多工作区布局恢复的完整实现。 全页对照恢复活动侧栏蓝色与焦点相关的标签边框;项目渐变由根容器在子元素前绘制,不覆盖不透明工具面板。首次打开仅展开 Project,Git 按需打开,已有保存布局保持优先。像素回归 TestVisualWorkbenchGradientStaysBehindPanels 证明修复前失败、修复后通过;Windows 原生验收仍待执行。" }