feat(ui): add collapsible persistent sidebar

This commit is contained in:
Logan.Li committed 2026-08-06 17:56:55 +08:00
1 parent 9ce71795b8
commit 101d2164b4
13 files changed
+153 -29

No files matched your search

+35 -13
View File
@@ -78,17 +78,46 @@ function App() {
}, [settings, settingsLoaded])
const isMobile = useIsMobile()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const setSetting = useSettingsStore((state) => state.setSetting)
const [sidebarCollapsed, setSidebarCollapsed] = useState(settings.sidebarCollapsed)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(true)
} else {
setSidebarCollapsed(false)
setMobileSidebarOpen(false)
return
}
}, [isMobile])
setMobileSidebarOpen(false)
if (settingsLoaded) {
setSidebarCollapsed(settings.sidebarCollapsed)
}
}, [isMobile, settingsLoaded, settings.sidebarCollapsed])
const toggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((prev) => !prev)
return
}
setSidebarCollapsed((prev) => {
const next = !prev
void setSetting('sidebarCollapsed', next)
return next
})
}, [isMobile, setSetting])
const toggleMobileSidebar = useCallback(() => {
setMobileSidebarOpen((prev) => !prev)
}, [])
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false)
}, [])
useEffect(() => {
const handleToggleSidebar = () => toggleSidebar()
window.addEventListener('opsxshell:toggle-sidebar', handleToggleSidebar)
return () => window.removeEventListener('opsxshell:toggle-sidebar', handleToggleSidebar)
}, [toggleSidebar])
// Keep renderer state authoritative when the native SSH service closes a
// connection (network loss, keepalive failure, or remote shell failure).
@@ -112,14 +141,6 @@ function App() {
}
}, [])
const toggleMobileSidebar = useCallback(() => {
setMobileSidebarOpen((prev) => !prev)
}, [])
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false)
}, [])
const handleOpenSettings = useCallback(() => {
useTabStore.getState().addTab({ title: '设置', type: 'settings' })
}, [])
@@ -361,6 +382,7 @@ function App() {
isMobile={isMobile}
sidebarCollapsed={sidebarCollapsed}
mobileSidebarOpen={mobileSidebarOpen}
onToggleSidebar={toggleSidebar}
onToggleMobileSidebar={toggleMobileSidebar}
onCloseMobileSidebar={closeMobileSidebar}
onNewSession={openNewSession}
+25 -3
View File
@@ -8,6 +8,7 @@
*/
import type { ReactNode } from 'react'
import { PanelLeft } from 'lucide-react'
import Sidebar from '../components/layout/Sidebar'
import TabBar from '../components/layout/TabBar'
import TabContent from '../components/layout/TabContent'
@@ -20,6 +21,7 @@ export interface AppShellProps {
isMobile: boolean
sidebarCollapsed: boolean
mobileSidebarOpen: boolean
onToggleSidebar: () => void
onToggleMobileSidebar: () => void
onCloseMobileSidebar: () => void
onNewSession: () => void
@@ -63,11 +65,26 @@ function MobileMenuButton({ onClick }: { onClick: () => void }) {
)
}
function ExpandSidebarButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="titlebar-no-drag flex h-10 w-10 flex-shrink-0 items-center justify-center border-r border-dark-700/40 text-txt-muted transition-colors hover:bg-dark-700/60 hover:text-content-primary"
title="展开侧栏 (Ctrl/Cmd+B)"
aria-label="展开侧栏"
>
<PanelLeft className="h-4 w-4" />
</button>
)
}
export default function AppShell({
activeTabId,
isMobile,
sidebarCollapsed,
mobileSidebarOpen,
onToggleSidebar,
onToggleMobileSidebar,
onCloseMobileSidebar,
onNewSession,
@@ -91,6 +108,8 @@ export default function AppShell({
if (isMobile) onCloseMobileSidebar()
}
const desktopSidebarHidden = !isMobile && sidebarCollapsed
return (
<div className="flex h-screen flex-col overflow-hidden bg-dark-900 text-content-primary">
<TitleBar />
@@ -105,24 +124,27 @@ export default function AppShell({
)}
<aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-all duration-300 ease-in-out ${
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: sidebarCollapsed
? 'w-0 overflow-hidden'
: desktopSidebarHidden
? 'w-0 overflow-hidden border-0'
: ''
} `}
aria-hidden={desktopSidebarHidden}
>
<Sidebar
onNewSession={handleSidebarNewSession}
onQuickConnect={handleSidebarQuickConnect}
onEditSession={handleSidebarEditSession}
onCollapse={isMobile ? undefined : onToggleSidebar}
/>
</aside>
<main className="flex min-w-0 flex-1 flex-col bg-dark-800">
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<TabBar onOpenLauncher={onOpenLauncher} />
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+10
View File
@@ -17,6 +17,7 @@ import {
ChevronRight,
Settings,
Laptop,
PanelLeft,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore, useSnippetStore, useCommandPaletteStore, type Session } from '../../stores'
@@ -108,6 +109,15 @@ export function CommandPalette({
icon: <Settings size={16} />,
action: () => onOpenSettings(),
},
{
id: 'cmd:toggle-sidebar',
type: 'command',
label: '折叠/展开侧栏',
description: '隐藏侧栏以专心操作终端',
shortcut: 'Ctrl+B',
icon: <PanelLeft size={16} />,
action: () => window.dispatchEvent(new CustomEvent('opsxshell:toggle-sidebar')),
},
{
id: 'cmd:new-tab',
type: 'command',
+31 -8
View File
@@ -4,7 +4,7 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Zap, Server, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare } from 'lucide-react'
import { Plus, Zap, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare, PanelLeftClose } from 'lucide-react'
import { useSessionStore, Session } from '../../stores'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
@@ -12,6 +12,7 @@ import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from '../common/ConfirmDialog'
import BatchCommandDialog from '../sessions/BatchCommandDialog'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
@@ -20,9 +21,10 @@ interface SidebarProps {
onNewSession: () => void
onQuickConnect: () => void
onEditSession: (session: Session) => void
onCollapse?: () => void
}
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
@@ -259,15 +261,31 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
{/* Header */}
<div className="p-3.5 border-b border-dark-700/30">
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
<div className="w-8 h-8 bg-gradient-to-br from-accent-blue to-accent-blue/70 rounded-lg flex items-center justify-center shadow-sm shadow-accent-blue/25">
<Server className="w-4 h-4 text-white" />
</div>
<div className="min-w-0">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
</div>
<div className="text-[10px] text-txt-faint mt-1">远程运维工作台</div>
<div className="text-[10px] text-txt-faint mt-1">SSH / SFTP 终端</div>
</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
title="折叠侧栏 (Ctrl/Cmd+B)"
aria-label="折叠侧栏"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
<PanelLeftClose className="h-4 w-4" />
</button>
)}
</div>
<div className="flex gap-1.5">
@@ -414,9 +432,14 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
)}
</div>
{/* Resize Handle */}
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
e.preventDefault()
onCollapse?.()
}}
title="拖拽调整宽度,双击折叠"
className={`
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
hover:bg-accent-blue/50 active:bg-accent-blue/70
+11 -5
View File
@@ -1,8 +1,9 @@
import { Terminal, Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
import { Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
import { useSessionStore } from '../../stores'
import { useTabStore } from '../../stores/tabStore'
import EmptyState from '../common/EmptyState'
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
interface WelcomeViewProps {
onNewSession?: () => void
@@ -27,12 +28,17 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
{/* Hero — 短文案 */}
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-blue/30 to-accent-purple/20 border border-dark-600/60 flex items-center justify-center shadow-lg shadow-black/20 flex-shrink-0">
<Terminal className="w-7 h-7 text-content-primary" />
</div>
<img
src={appIconUrl}
alt="OpsXShell"
width={56}
height={56}
className="h-14 w-14 flex-shrink-0 rounded-2xl object-cover shadow-lg shadow-black/20"
draggable={false}
/>
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 跨平台运维工作台</p>
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 终端</p>
</div>
</div>
+17
View File
@@ -216,6 +216,23 @@ export default function SettingsView() {
<span className="w-12 text-right text-xs tabular-nums text-txt-muted">{settings.sidebarWidth}px</span>
</div>
</SettingRow>
<SettingRow label="折叠侧栏" description="隐藏会话树以专心操作终端,可用 Ctrl/Cmd+B 切换">
<button
type="button"
role="switch"
aria-checked={settings.sidebarCollapsed}
onClick={() => setSetting('sidebarCollapsed', !settings.sidebarCollapsed)}
className={`relative h-6 w-11 rounded-full transition-colors ${
settings.sidebarCollapsed ? 'bg-accent-blue' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</SettingRow>
</SettingGroup>
</div>
)
+8
View File
@@ -16,6 +16,7 @@ export const KeybindingAction = {
// 视图
VIEW_FULLSCREEN: 'view.fullscreen',
VIEW_TOGGLE_SIDEBAR: 'view.toggleSidebar',
// 会话
SESSION_NEW: 'session.new',
@@ -95,6 +96,13 @@ export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [
defaultKeys: 'F11',
fireInInput: true,
},
{
id: KeybindingAction.VIEW_TOGGLE_SIDEBAR,
label: '折叠/展开侧栏',
category: KeybindingCategory.VIEW,
defaultKeys: 'Ctrl+B',
fireInInput: true,
},
{
id: KeybindingAction.SESSION_NEW,
label: '新建会话',
+9
View File
@@ -9,6 +9,7 @@
* - Ctrl+1~9: 跳转到指定索引标签
* - Ctrl+W: 关闭当前标签
* - F11: 全屏切换
* - Ctrl/Cmd+B: 折叠/展开侧栏
* - Ctrl+Shift+N: 新建标签
* - Ctrl+Shift+P: 打开命令面板
* - Ctrl+N: 新建会话
@@ -125,6 +126,7 @@ function shouldFireInInput(e: KeyboardEvent): boolean {
KeybindingAction.TAB_PREV,
KeybindingAction.TAB_GOTO_N,
KeybindingAction.VIEW_FULLSCREEN,
KeybindingAction.VIEW_TOGGLE_SIDEBAR,
KeybindingAction.TOOLS_COMMAND_PALETTE,
]
@@ -202,6 +204,13 @@ export function useKeyboardShortcuts(): void {
return
}
// ---- Ctrl/Cmd+B: 折叠侧栏 ----
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_TOGGLE_SIDEBAR])) {
e.preventDefault()
window.dispatchEvent(new CustomEvent('opsxshell:toggle-sidebar'))
return
}
// ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ----
if (isInputElement(e) && !shouldFireInInput(e)) {
return
+3
View File
@@ -12,6 +12,8 @@ export interface AppSettings {
// 外观
theme: 'dark' | 'light' | 'system'
sidebarWidth: number
/** 桌面侧栏是否折叠(专心终端) */
sidebarCollapsed: boolean
// 终端
fontFamily: string
@@ -41,6 +43,7 @@ export interface AppSettings {
const defaultSettings: AppSettings = {
theme: 'dark',
sidebarWidth: 256,
sidebarCollapsed: false,
// Prefer Nerd Font so local zsh powerline prompts render correctly
fontFamily: 'MesloLGS NF',
fontSize: 14,