From 2db388c2f21f103e3014e2fb9c39125850fecb54 Mon Sep 17 00:00:00 2001 From: zzzjy Date: Thu, 24 Sep 2026 22:57:47 +0800 Subject: [PATCH] feat: merge titlebar and tab bar into single header with dynamic workspace alignment - Merge titlebar and tab bar into a single unified top bar (.titlebar) - Remove redundant floating workspace button that caused overlap on collapsed sidebar - Ensure tab bar left aligns with terminal window when workspace sidebar is expanded - Allow tab bar to sit naturally at leftmost position when workspace sidebar is collapsed - Update test runner suite to cover merged titlebar and alignment behavior --- src/renderer/css/app.css | 95 ++++++++++++++-------------- src/renderer/css/sidebar.css | 41 ------------ src/renderer/css/tabs.css | 3 +- src/renderer/css/variables.css | 1 + src/renderer/index.html | 88 +++++++++++--------------- src/renderer/js/app.js | 6 +- src/renderer/js/workspace-manager.js | 17 +++-- test/test-runner.cjs | 66 ++++++++++++------- 8 files changed, 143 insertions(+), 174 deletions(-) diff --git a/src/renderer/css/app.css b/src/renderer/css/app.css index 64dd3d8..6a4ba5e 100644 --- a/src/renderer/css/app.css +++ b/src/renderer/css/app.css @@ -28,26 +28,47 @@ body { box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } -/* 顶部无边框拖拽标题栏 */ +/* 顶部无边框拖拽标题栏 (与标签栏合二为一) */ .titlebar { height: var(--titlebar-height); background: var(--bg-titlebar); display: flex; align-items: center; - justify-content: space-between; border-bottom: 1px solid var(--border-color); -webkit-app-region: drag; /* 允许窗口拖拽 */ position: relative; z-index: 100; - padding: 0 8px; + padding: 0; + padding-right: 8px; + box-sizing: border-box; } .titlebar-left { display: flex; align-items: center; gap: 6px; + height: 100%; + padding: 0 8px; + width: var(--sidebar-width, 250px); + min-width: var(--sidebar-width, 250px); + max-width: var(--sidebar-width, 250px); + border-right: 1px solid var(--border-color); + -webkit-app-region: no-drag; overflow: hidden; - -webkit-app-region: no-drag; /* 允许子元素正常响应鼠标交互 */ + box-sizing: border-box; + flex-shrink: 0; + transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +#app.sidebar-collapsed .titlebar-left, +.titlebar-left.sidebar-collapsed { + width: auto; + min-width: 0; + max-width: none; + border-right: none; + padding: 0 8px; } .app-brand { @@ -57,7 +78,7 @@ body { font-size: 13px; font-weight: 700; color: var(--accent-color); - padding: 0 8px; + padding: 0 4px; letter-spacing: 0.5px; -webkit-app-region: drag; cursor: grab; @@ -69,7 +90,7 @@ body { display: inline-flex; align-items: center; gap: 6px; - padding: 2px 10px; + padding: 2px 8px; height: 22px; background: var(--bg-tab-inactive); border: 1px solid var(--border-color); @@ -80,6 +101,9 @@ body { user-select: none; -webkit-app-region: no-drag !important; transition: all 0.15s ease; + flex-shrink: 1; + min-width: 0; + overflow: hidden; } .titlebar-workspace-tag:hover { @@ -99,9 +123,23 @@ body { .ws-tag-name { font-weight: 500; - max-width: 160px; + max-width: 95px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +/* 标签页栏容器 (位于顶栏中央,工作区展开时与终端左侧像素对齐) */ +.tab-bar-container { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: calc(100vw - 360px); + padding: 0; + height: 100%; + -webkit-app-region: no-drag !important; + box-sizing: border-box; } .titlebar-drag-spacer { @@ -109,7 +147,7 @@ body { height: 100%; -webkit-app-region: drag; /* 中间大面积空白区域允许拖拽 */ cursor: grab; - min-width: 40px; + min-width: 20px; } .app-brand-icon { @@ -193,47 +231,6 @@ body { color: #ffffff; } -/* 工作区终端主舞台与标签页栏 (工作区子层级,与终端严格对齐) */ -.workspace-stage { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - position: relative; - overflow: hidden; - background: transparent; -} - -.workspace-header { - height: 38px; - min-height: 38px; - background: var(--bg-titlebar); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px; - gap: 8px; - user-select: none; - z-index: 20; -} - -.tab-bar-container { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; - overflow: hidden; -} - -.stage-actions { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} - /* 主内容区终端视口 */ .main-viewport { flex: 1; diff --git a/src/renderer/css/sidebar.css b/src/renderer/css/sidebar.css index 61a6a6a..1c78cf0 100644 --- a/src/renderer/css/sidebar.css +++ b/src/renderer/css/sidebar.css @@ -385,44 +385,3 @@ background: var(--btn-hover, rgba(255, 255, 255, 0.08)); } -/* 侧边栏折叠时的浮动快捷呼出按钮 */ -.sidebar-floating-toggle { - display: none; - position: absolute; - top: 10px; - left: 10px; - z-index: 35; - background: var(--bg-titlebar); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - color: var(--text-muted); - font-size: 11px; - padding: 4px 10px; - cursor: pointer; - align-items: center; - gap: 5px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0; - pointer-events: none; - transform: translateX(-15px); -} - -.app-body.sidebar-collapsed .sidebar-floating-toggle, -.workspace-sidebar.collapsed ~ .sidebar-floating-toggle { - display: flex; - opacity: 0.9; - pointer-events: auto; - transform: translateX(0); -} - -.sidebar-floating-toggle:hover { - opacity: 1; - background: var(--bg-tab-hover); - color: var(--accent-color); - border-color: var(--border-active); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 10px var(--accent-glow); -} - diff --git a/src/renderer/css/tabs.css b/src/renderer/css/tabs.css index c8c57ab..5217ecc 100644 --- a/src/renderer/css/tabs.css +++ b/src/renderer/css/tabs.css @@ -5,8 +5,9 @@ gap: 4px; overflow-x: auto; scrollbar-width: none; - flex: 1; + flex: 0 1 auto; min-width: 0; + padding: 0 6px; -webkit-app-region: no-drag !important; } diff --git a/src/renderer/css/variables.css b/src/renderer/css/variables.css index 55dcc25..646d738 100644 --- a/src/renderer/css/variables.css +++ b/src/renderer/css/variables.css @@ -18,6 +18,7 @@ --titlebar-height: 38px; --statusbar-height: 26px; + --sidebar-width: 250px; --border-radius-sm: 4px; --border-radius-md: 8px; --border-radius-lg: 12px; diff --git a/src/renderer/index.html b/src/renderer/index.html index 9dc6186..28463df 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -17,13 +17,13 @@
- -
-
+ +
+
@@ -43,11 +43,40 @@
+ +
+
+ +
+
+ + +
- +
- - - - -
- -
-
-
- -
- -
- - - -
-
- - -
-
+ +
diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index f9dd59a..10629f6 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -117,6 +117,10 @@ class TerminalApp { if (tab) tab.splitTree.splitCurrent('vertical'); }); + document.getElementById('btn-search')?.addEventListener('click', () => { + this.toggleSearch(); + }); + document.getElementById('status-theme')?.addEventListener('click', () => { this.commandPalette.open(); this.commandPalette.input.value = '切换主题'; @@ -339,5 +343,5 @@ class TerminalApp { // 页面加载就绪后启动终端 window.addEventListener('DOMContentLoaded', () => { - new TerminalApp(); + window.terminalApp = new TerminalApp(); }); diff --git a/src/renderer/js/workspace-manager.js b/src/renderer/js/workspace-manager.js index 6645fea..ab3133c 100644 --- a/src/renderer/js/workspace-manager.js +++ b/src/renderer/js/workspace-manager.js @@ -125,6 +125,9 @@ export class WorkspaceManager { this.isCollapsed = localStorage.getItem('hatty:sidebar-collapsed') === 'true'; if (this.isCollapsed) { this.sidebar?.classList.add('collapsed'); + document.querySelector('.app-body')?.classList.add('sidebar-collapsed'); + document.getElementById('app')?.classList.add('sidebar-collapsed'); + document.getElementById('titlebar-left')?.classList.add('sidebar-collapsed'); } } catch (e) { console.warn('Failed to load workspace state:', e); @@ -154,12 +157,6 @@ export class WorkspaceManager { this.setCollapsed(true); }); - // 浮动快捷呼出按钮 - document.getElementById('sidebar-floating-toggle')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.setCollapsed(false); - }); - // 新建工作区按钮 document.getElementById('btn-add-workspace')?.addEventListener('click', () => { this.openCreateModal(); @@ -202,6 +199,14 @@ export class WorkspaceManager { if (appBody) { appBody.classList.toggle('sidebar-collapsed', collapsed); } + const app = document.getElementById('app'); + if (app) { + app.classList.toggle('sidebar-collapsed', collapsed); + } + const titlebarLeft = document.getElementById('titlebar-left'); + if (titlebarLeft) { + titlebarLeft.classList.toggle('sidebar-collapsed', collapsed); + } const toggleBtn = document.getElementById('btn-toggle-sidebar'); if (toggleBtn) { toggleBtn.classList.toggle('active', !collapsed); diff --git a/test/test-runner.cjs b/test/test-runner.cjs index 235b867..2770db1 100644 --- a/test/test-runner.cjs +++ b/test/test-runner.cjs @@ -150,9 +150,9 @@ app.whenReady().then(async () => { `); // ========================================== - // Suite 2: Workspace Hierarchy & Stage Alignment + // Suite 2: Merged Titlebar & Workspace Alignment // ========================================== - runner.suite('2. 工作区层级结构与视口对齐验证 (UI Architecture)'); + runner.suite('2. 顶栏与标签页栏合二为一与工作区对齐验证 (UI Architecture)'); await runner.test('标题栏与工作区标识层级正确 (Titlebar Header)', async () => { const res = await win.webContents.executeJavaScript(` @@ -172,50 +172,68 @@ app.whenReady().then(async () => { if (!res.wsName) throw new Error('Active workspace tag name is empty'); }); - await runner.test('工作区终端舞台包含独立头部标签条与视口 (.workspace-stage)', async () => { + await runner.test('顶栏和标签页栏合二为一 (Merged Titlebar and Tab Bar)', async () => { const res = await win.webContents.executeJavaScript(` (() => { - const stage = document.querySelector('.workspace-stage'); - const header = document.querySelector('.workspace-header'); - const viewport = document.getElementById('main-viewport'); + const titlebar = document.querySelector('.titlebar'); + const tabBarContainer = document.getElementById('tab-bar-container'); const tabBar = document.getElementById('tab-bar'); const newTabBtn = document.getElementById('btn-new-tab'); + const viewport = document.getElementById('main-viewport'); + const hasOldStage = !!document.querySelector('.workspace-header'); return { - hasStage: !!stage, - hasHeader: !!header, + titlebarHasContainer: titlebar?.contains(tabBarContainer), + containerHasTabs: tabBarContainer?.contains(tabBar), + containerHasBtn: tabBarContainer?.contains(newTabBtn), hasViewport: !!viewport, - tabsInHeader: header?.contains(tabBar), - btnInHeader: header?.contains(newTabBtn), + hasOldHeader: hasOldStage }; })() `); - if (!res.hasStage) throw new Error('Missing .workspace-stage container'); - if (!res.hasHeader) throw new Error('Missing .workspace-header'); + if (!res.titlebarHasContainer) throw new Error('#tab-bar-container must be inside .titlebar'); + if (!res.containerHasTabs) throw new Error('#tab-bar must be inside #tab-bar-container'); + if (!res.containerHasBtn) throw new Error('#btn-new-tab must be inside #tab-bar-container'); if (!res.hasViewport) throw new Error('Missing #main-viewport'); - if (!res.tabsInHeader) throw new Error('#tab-bar must be inside .workspace-header'); - if (!res.btnInHeader) throw new Error('#btn-new-tab must be inside .workspace-header'); + if (res.hasOldHeader) throw new Error('.workspace-header should be removed (merged into titlebar)'); }); - await runner.test('标签栏与终端窗口边缘完全像素级对齐 (100% Alignment)', async () => { + await runner.test('工作区展开时标签页和终端窗口左边像素级对齐', async () => { const res = await win.webContents.executeJavaScript(` (() => { - const hRect = document.querySelector('.workspace-header').getBoundingClientRect(); + const tRect = document.getElementById('tab-bar-container').getBoundingClientRect(); const vRect = document.getElementById('main-viewport').getBoundingClientRect(); return { - headerLeft: hRect.left, + tabLeft: tRect.left, viewportLeft: vRect.left, - headerWidth: hRect.width, - viewportWidth: vRect.width, - leftDiff: Math.abs(hRect.left - vRect.left), - widthDiff: Math.abs(hRect.width - vRect.width) + leftDiff: Math.abs(tRect.left - vRect.left) }; })() `); if (res.leftDiff >= 1) { - throw new Error(`Left mismatch: header.left=${res.headerLeft}, viewport.left=${res.viewportLeft}`); + throw new Error(`Left mismatch: tabLeft=${res.tabLeft}, viewportLeft=${res.viewportLeft}`); } - if (res.widthDiff >= 1) { - throw new Error(`Width mismatch: header.width=${res.headerWidth}, viewport.width=${res.viewportWidth}`); + }); + + await runner.test('工作区关闭时标签页位于左侧且不强求与终端左边对齐', async () => { + const res = await win.webContents.executeJavaScript(` + (() => { + // 折叠侧边栏 + window.terminalApp?.workspaceManager?.setCollapsed(true); + const tRect = document.getElementById('tab-bar-container').getBoundingClientRect(); + const titlebarLeft = document.getElementById('titlebar-left').getBoundingClientRect(); + const vRect = document.getElementById('main-viewport').getBoundingClientRect(); + // 恢复展开状态供后续测试 + window.terminalApp?.workspaceManager?.setCollapsed(false); + return { + tabLeft: tRect.left, + controlsRight: titlebarLeft.right, + viewportLeft: vRect.left, + distFromControls: Math.abs(tRect.left - titlebarLeft.right) + }; + })() + `); + if (res.distFromControls >= 2) { + throw new Error(`Tab bar not immediately beside titlebar-left controls when collapsed: tabLeft=${res.tabLeft}, controlsRight=${res.controlsRight}`); } });