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
This commit is contained in:
2026-09-24 22:57:47 +08:00
parent 5a032b13ec
commit 2db388c2f2
8 changed files with 143 additions and 174 deletions
+46 -49
View File
@@ -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;
-41
View File
@@ -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);
}
+2 -1
View File
@@ -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;
}
+1
View File
@@ -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;
+36 -52
View File
@@ -17,13 +17,13 @@
</head>
<body>
<div id="app">
<!-- 顶部现代拖拽式标题栏 -->
<header class="titlebar">
<div class="titlebar-left">
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
<header class="titlebar" id="titlebar">
<div class="titlebar-left" id="titlebar-left">
<!-- 侧边栏折叠/展开按钮 -->
<button class="btn-icon btn-toggle-sidebar" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
</button>
@@ -43,11 +43,40 @@
</div>
</div>
<!-- 标签页栏与新建按钮 -->
<div class="tab-bar-container" id="tab-bar-container">
<div class="tab-bar" id="tab-bar"></div>
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<!-- 中间空白可拖拽区域 -->
<div class="titlebar-drag-spacer" id="titlebar-drag-spacer"></div>
<!-- 右侧快速操作按钮与窗口控制 -->
<div class="titlebar-actions">
<button class="btn-icon" id="btn-split-h" title="向右水平分屏 (Ctrl+Shift+E)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
</button>
<button class="btn-icon" id="btn-split-v" title="向下垂直分屏 (Ctrl+Shift+O)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="3" y1="12" x2="21" y2="12"></line>
</svg>
</button>
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button class="btn-icon" id="btn-palette" title="快速命令面板 (Ctrl+Shift+P / F1)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
@@ -83,7 +112,7 @@
</div>
</header>
<!-- 应用主体容器 (含左侧工作区侧边栏与主视口) -->
<!-- 应用主体容器 (含左侧工作区侧边栏与终端视口) -->
<div class="app-body">
<!-- 左侧工作区侧边栏 -->
<aside class="workspace-sidebar" id="workspace-sidebar">
@@ -124,53 +153,8 @@
</div>
</aside>
<!-- 折叠时的浮动快捷呼出按钮 -->
<button class="sidebar-floating-toggle" id="sidebar-floating-toggle" title="展开工作区侧边栏 (Ctrl+B)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
<span>工作区</span>
</button>
<!-- 右侧工作区终端主舞台 (标签页为工作区子层级,与终端视口对齐) -->
<div class="workspace-stage">
<!-- 标签页栏与终端操作条 (与终端视口完全对齐) -->
<div class="workspace-header">
<div class="tab-bar-container">
<div class="tab-bar" id="tab-bar"></div>
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div class="stage-actions">
<button class="btn-icon" id="btn-split-h" title="向右水平分屏 (Ctrl+Shift+E)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
</button>
<button class="btn-icon" id="btn-split-v" title="向下垂直分屏 (Ctrl+Shift+O)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="3" y1="12" x2="21" y2="12"></line>
</svg>
</button>
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
</div>
</div>
<!-- 终端主视口 -->
<main class="main-viewport" id="main-viewport"></main>
</div>
<!-- 终端主视口 -->
<main class="main-viewport" id="main-viewport"></main>
</div>
<!-- 底部状态栏 -->
+5 -1
View File
@@ -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();
});
+11 -6
View File
@@ -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);
+42 -24
View File
@@ -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}`);
}
});