remove: drop the quick-input panel
The toggle state was a global singleton while the bolt button renders in every pane's tab bar, so all panes showed the same open/close state and the single floating panel (window bottom-right, sends to the *active* session) never corresponded to the pane whose button was clicked. A true per-pane rework is a medium refactor for a feature that overlaps with the inline completion and the sidebar commands panel — removing instead.
This commit is contained in:
1 parent
fcb2781694
commit
f09bed06b5
4 files changed
+4
-352
No files matched your search
+1
-1
@@ -73,7 +73,7 @@
|
||||
## M6 — 多终端协同 ✅
|
||||
|
||||
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
|
||||
- [x] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 自动执行(\r) + 快捷命令 + 最近命令;广播感知发送
|
||||
- [x] ~~快捷输入面板(Quick Input)~~(已移除:全局单例开关与面板不对一一对应,功能与行内补全/侧边栏命令面板重叠)
|
||||
- [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
|
||||
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
||||
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { App, Input } from 'antd'
|
||||
import { create } from 'zustand'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import { writeBroadcast } from './broadcastStore'
|
||||
|
||||
/** Open state shared between the tab-bar toggle button and the floating panel. */
|
||||
export const useQuickInputStore = create<{
|
||||
open: boolean
|
||||
toggle: () => void
|
||||
setOpen: (open: boolean) => void
|
||||
}>((set) => ({
|
||||
open: window.localStorage.getItem('ot.quickInputOpen') === '1',
|
||||
toggle: () =>
|
||||
set((state) => {
|
||||
const next = !state.open
|
||||
window.localStorage.setItem('ot.quickInputOpen', next ? '1' : '0')
|
||||
return { open: next }
|
||||
}),
|
||||
setOpen: (open) => {
|
||||
window.localStorage.setItem('ot.quickInputOpen', open ? '1' : '0')
|
||||
set({ open })
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* M6 quick-input panel (bottom-right floating). Three sections:
|
||||
* 1. free-text command line (with auto-execute(\r) toggle)
|
||||
* 2. library commands (api.listLibrary())
|
||||
* 3. recent history (api.listHistory(), top 10)
|
||||
*
|
||||
* Sends target = the active session via `onResolveActiveSession`; when broadcast
|
||||
* is enabled and the active session is a target, the send routes through the
|
||||
* broadcast fan-out so it reaches every target. Lists refresh on open.
|
||||
*/
|
||||
export interface QuickInputPanelProps {
|
||||
/** resolve the currently active terminal session id ('' when none) */
|
||||
onResolveActiveSession: () => string
|
||||
}
|
||||
|
||||
export function QuickInputPanel({ onResolveActiveSession }: QuickInputPanelProps): React.JSX.Element | null {
|
||||
const { message } = App.useApp()
|
||||
const open = useQuickInputStore((s) => s.open)
|
||||
const close = useQuickInputStore((s) => s.setOpen)
|
||||
// auto-execute(\r); persisted across sessions.
|
||||
const [autoEnter, setAutoEnter] = useState<boolean>(() => window.localStorage.getItem('ot.quickInputEnter') !== '0')
|
||||
const [value, setValue] = useState('')
|
||||
const [history, setHistory] = useState<CommandItem[]>([])
|
||||
const [library, setLibrary] = useState<CommandItem[]>([])
|
||||
/** tracks the previous `open` so lists refresh on the false→true transition */
|
||||
const prevOpenRef = useRef(open)
|
||||
|
||||
// Refresh lists every time the panel opens (and stay fresh if it was open).
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [h, l] = await Promise.all([window.api.listHistory(), window.api.listLibrary()])
|
||||
setHistory(h.slice(0, 10))
|
||||
setLibrary(l)
|
||||
} catch {
|
||||
setHistory([])
|
||||
setLibrary([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) void refresh()
|
||||
prevOpenRef.current = open
|
||||
}, [open, refresh])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
const target = onResolveActiveSession()
|
||||
if (!target) {
|
||||
void message.info('没有激活的终端会话')
|
||||
return
|
||||
}
|
||||
const data = autoEnter ? `${text}\r` : text
|
||||
writeBroadcast(target, data)
|
||||
// keep "最近命令" fresh immediately after a send
|
||||
void window.api
|
||||
.listHistory()
|
||||
.then((h: CommandItem[]) => setHistory(h.slice(0, 10)))
|
||||
.catch(() => undefined)
|
||||
setValue('')
|
||||
},
|
||||
[autoEnter, onResolveActiveSession]
|
||||
)
|
||||
|
||||
const onEnter = useCallback((): void => {
|
||||
const t = value.trim()
|
||||
if (t) send(t)
|
||||
}, [value, send])
|
||||
|
||||
const onToggleAutoEnter = useCallback((checked: boolean): void => {
|
||||
setAutoEnter(checked)
|
||||
window.localStorage.setItem('ot.quickInputEnter', checked ? '1' : '0')
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="quick-input-panel">
|
||||
<div className="quick-input-headbar">
|
||||
<span className="quick-input-headbar-title">快捷输入</span>
|
||||
<button type="button" className="quick-input-close" title="收起" aria-label="收起" onClick={() => close(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-input-row">
|
||||
<Input
|
||||
className="quick-input-field"
|
||||
placeholder="输入命令…"
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onPressEnter={onEnter}
|
||||
/>
|
||||
<label className="quick-input-autoenter" title="关闭后只键入不回车">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnter}
|
||||
onChange={(e) => onToggleAutoEnter(e.target.checked)}
|
||||
/>
|
||||
<span>自动执行(\r)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-input-section">
|
||||
<div className="quick-input-head">快捷命令</div>
|
||||
{library.length === 0 ? (
|
||||
<div className="quick-input-empty">暂无快捷命令</div>
|
||||
) : (
|
||||
<div className="quick-input-list">
|
||||
{library.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="quick-input-item"
|
||||
title={item.command}
|
||||
onClick={() => send(item.command)}
|
||||
>
|
||||
<span className="quick-input-item-cmd">{item.command}</span>
|
||||
{item.name && <span className="quick-input-item-tag">{item.name}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-input-section">
|
||||
<div className="quick-input-head">最近命令</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="quick-input-empty">暂无历史记录</div>
|
||||
) : (
|
||||
<div className="quick-input-list">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="quick-input-item"
|
||||
title={item.command}
|
||||
onClick={() => send(item.command)}
|
||||
>
|
||||
<span className="quick-input-item-cmd">{item.command}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
SettingOutlined,
|
||||
ShareAltOutlined,
|
||||
ThunderboltOutlined
|
||||
ShareAltOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { DockviewReact } from 'dockview-react'
|
||||
import type {
|
||||
@@ -33,7 +32,6 @@ import { HostKeyModal } from './HostKeyModal'
|
||||
import { TerminalPanel } from './TerminalPanel'
|
||||
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
||||
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
||||
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
|
||||
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
||||
@@ -201,7 +199,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
||||
<div className="workspace-tabbar-actions">
|
||||
<AddTerminalGroupButton onClick={handleAdd} />
|
||||
<BroadcastToggleButton />
|
||||
<QuickInputToggleButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -240,7 +237,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** Per-workspace active panel used as the split anchor. */
|
||||
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
/** The currently-visible mode's active panel (broadcast / quick-input target). */
|
||||
/** The currently-visible mode's active panel (broadcast target). */
|
||||
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
|
||||
/** Sessions we already asked to kill — kill once, never twice. */
|
||||
@@ -961,13 +958,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}
|
||||
}
|
||||
|
||||
/** Session id of the currently active, visible terminal panel ('' when none). */
|
||||
const resolveActiveSessionId = useCallback((): string => {
|
||||
const panel = activePanelRef.current
|
||||
if (!panel) return ''
|
||||
return sessionIdOf(panel) ?? ''
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="workspace-root">
|
||||
<IconRail
|
||||
@@ -1056,11 +1046,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M6: floating quick-input panel (bottom-right). Sends to the active
|
||||
session; broadcast target → fan-out. Sits above the sftp transfer
|
||||
overlay's 14px bottom offset so the two never overlap. */}
|
||||
<QuickInputPanel onResolveActiveSession={resolveActiveSessionId} />
|
||||
|
||||
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
|
||||
{requestedConn != null && (
|
||||
<ConnectFlow
|
||||
@@ -1206,25 +1191,6 @@ function WorkspaceBroadcastPopover({
|
||||
)
|
||||
}
|
||||
|
||||
/** 快捷输入 panel toggle (ThunderboltOutlined) — state lives in the shared store. */
|
||||
function QuickInputToggleButton(): React.JSX.Element {
|
||||
const toggle = useQuickInputStore((s) => s.toggle)
|
||||
const open = useQuickInputStore((s) => s.open)
|
||||
return (
|
||||
<Tooltip title={open ? '收起快捷输入面板' : '打开快捷输入面板'}>
|
||||
<button
|
||||
type="button"
|
||||
className={['workspace-tabbar-btn2', open ? ' is-active' : ''].join('')}
|
||||
aria-label="快捷输入面板"
|
||||
aria-pressed={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
||||
* Top: workspace actions; bottom: settings.
|
||||
|
||||
@@ -446,7 +446,7 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
color: var(--tab-accent-fg, #3fd06a);
|
||||
}
|
||||
|
||||
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
|
||||
/* ---- M6 tab-bar workspace actions (broadcast) ---- */
|
||||
.workspace-tabbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -574,147 +574,6 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- M6 quick-input floating panel (bottom-right) ----
|
||||
bottom: 44px so it clears the sftp transfer overlay (right/bottom 14px). */
|
||||
.quick-input-panel {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 44px;
|
||||
z-index: 950;
|
||||
width: 320px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-headbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quick-input-headbar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #8a8a8a;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.quick-input-close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.quick-input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-input-field .ant-input {
|
||||
background: var(--chrome-bg);
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-autoenter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: #9a9a9a;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quick-input-autoenter input {
|
||||
accent-color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
.quick-input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.quick-input-head {
|
||||
font-size: 11px;
|
||||
color: #8a8a8a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.quick-input-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-input-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-input-item:hover {
|
||||
background: rgba(59, 142, 234, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.quick-input-item-cmd {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
|
||||
}
|
||||
|
||||
.quick-input-item-tag {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
color: #9a9a9a;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.quick-input-empty {
|
||||
padding: 8px 4px;
|
||||
color: #8a8a8a;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- custom tab (title + close + context menu) ---- */
|
||||
.workspace-terminal-tab {
|
||||
position: relative;
|
||||
|
||||
Reference in new issue
Block a user