Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03aa79a691 | ||
|
|
458dd9c88b | ||
|
|
28d8af6982 | ||
|
|
f09bed06b5 |
No files matched your search
+1
-1
@@ -73,7 +73,7 @@
|
|||||||
## M6 — 多终端协同 ✅
|
## M6 — 多终端协同 ✅
|
||||||
|
|
||||||
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
|
- [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] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
|
||||||
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
||||||
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"productName": "OpenTerminal",
|
"productName": "OpenTerminal",
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "Bill",
|
"author": "Bill",
|
||||||
|
|||||||
+19
-1
@@ -26,6 +26,7 @@ import { join } from 'path'
|
|||||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||||
import { loadSettings } from './settingsStore'
|
import { loadSettings } from './settingsStore'
|
||||||
|
import { LogSanitizer } from './logSanitizer'
|
||||||
|
|
||||||
/** Upper bound on recorded history entries when no limit is configured. */
|
/** Upper bound on recorded history entries when no limit is configured. */
|
||||||
const HISTORY_CAP = 500
|
const HISTORY_CAP = 500
|
||||||
@@ -77,6 +78,8 @@ export class CommandsStore {
|
|||||||
* onto the previous one keeps the file append-only in call order.
|
* onto the previous one keeps the file append-only in call order.
|
||||||
*/
|
*/
|
||||||
private readonly pendingAppends = new Map<string, Promise<void>>()
|
private readonly pendingAppends = new Map<string, Promise<void>>()
|
||||||
|
/** Plain-text transformer per actively-logged session (see logSanitizer). */
|
||||||
|
private readonly sanitizers = new Map<string, LogSanitizer>()
|
||||||
|
|
||||||
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
||||||
const ud = userData ?? app.getPath('userData')
|
const ud = userData ?? app.getPath('userData')
|
||||||
@@ -269,6 +272,7 @@ export class CommandsStore {
|
|||||||
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
|
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
|
||||||
this.metasByFile.set(file, meta)
|
this.metasByFile.set(file, meta)
|
||||||
this.activeBySession.set(sessionId, meta)
|
this.activeBySession.set(sessionId, meta)
|
||||||
|
this.sanitizers.set(sessionId, new LogSanitizer())
|
||||||
this.persistIndex()
|
this.persistIndex()
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
@@ -280,9 +284,12 @@ export class CommandsStore {
|
|||||||
// not logging (or already stopped) -> ignore, prevents stop/append race
|
// not logging (or already stopped) -> ignore, prevents stop/append race
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Raw PTY output is escape-sequence soup in a text file; log plain text.
|
||||||
|
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
|
||||||
|
if (!clean) return
|
||||||
// Chain onto any in-flight append so rapid writes land in call order.
|
// Chain onto any in-flight append so rapid writes land in call order.
|
||||||
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
||||||
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then(
|
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
() => {
|
() => {
|
||||||
// file may have been removed after stop -> ignore
|
// file may have been removed after stop -> ignore
|
||||||
@@ -299,6 +306,17 @@ export class CommandsStore {
|
|||||||
logStop(sessionId: string): void {
|
logStop(sessionId: string): void {
|
||||||
const meta = this.activeBySession.get(sessionId)
|
const meta = this.activeBySession.get(sessionId)
|
||||||
if (!meta) return
|
if (!meta) return
|
||||||
|
const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
|
||||||
|
this.sanitizers.delete(sessionId)
|
||||||
|
if (tail) {
|
||||||
|
// Best effort: the trailing partial line belongs in the file too.
|
||||||
|
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
||||||
|
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
this.pendingAppends.set(meta.file, next)
|
||||||
|
}
|
||||||
this.finishLog(meta)
|
this.finishLog(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Plain-text sanitizer for session logs.
|
||||||
|
*
|
||||||
|
* Raw PTY output is unreadable in a text file: SGR colors, cursor moves,
|
||||||
|
* synchronized-output markers — and for full-screen TUIs (vim, htop) every
|
||||||
|
* redrawn frame. For each logged session this filter:
|
||||||
|
* 1. strips ANSI escape sequences (CSI / OSC / single-char), statefully
|
||||||
|
* across chunk boundaries (a sequence may split between two PTY chunks);
|
||||||
|
* 2. collapses carriage-return overwrites (progress bars, spinner lines)
|
||||||
|
* keeping only the final text of the line — a `\r` directly followed by
|
||||||
|
* `\n` is a line ending, not an overwrite;
|
||||||
|
* 3. drops output produced while the alternate screen is active (TUI
|
||||||
|
* redraw frames), emitting one marker line per suppressed span.
|
||||||
|
*
|
||||||
|
* Note: inline TUIs that redraw in the *normal* buffer (Ink-based CLIs such
|
||||||
|
* as Claude Code / kimi) cannot be frame-collapsed without full screen
|
||||||
|
* emulation; their committed lines are preserved as-is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
|
||||||
|
|
||||||
|
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
|
||||||
|
|
||||||
|
/** Alt-screen toggles: CSI ? 1049|1047|47 h / l */
|
||||||
|
const ALT_ON = /^\?(?:1049|1047|47)h$/
|
||||||
|
const ALT_OFF = /^\?(?:1049|1047|47)l$/
|
||||||
|
|
||||||
|
export class LogSanitizer {
|
||||||
|
private mode: Mode = 'text'
|
||||||
|
/** raw bytes of the escape sequence in flight (bounded: OSC can be long) */
|
||||||
|
private seq = ''
|
||||||
|
private alt = false
|
||||||
|
/** some output was suppressed since the last marker */
|
||||||
|
private altDirty = false
|
||||||
|
/** current line, not yet newline-terminated */
|
||||||
|
private line = ''
|
||||||
|
/** previous text char was \r — the next char decides ending vs overwrite */
|
||||||
|
private cr = false
|
||||||
|
|
||||||
|
/** Transform one raw chunk; returns the plain text to append ('' for none). */
|
||||||
|
push(data: string): string {
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = data[i]
|
||||||
|
|
||||||
|
// A pending \r: `\r\n` completes the line; anything else overwrites it.
|
||||||
|
if (this.cr) {
|
||||||
|
this.cr = false
|
||||||
|
if (c === '\n') {
|
||||||
|
out += this.emitLine()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.line = ''
|
||||||
|
// fall through and process c normally
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.mode) {
|
||||||
|
case 'text':
|
||||||
|
if (c === '\x1b') {
|
||||||
|
this.mode = 'esc'
|
||||||
|
this.seq = ''
|
||||||
|
} else if (c === '\r') {
|
||||||
|
this.cr = true
|
||||||
|
} else if (c === '\n') {
|
||||||
|
out += this.emitLine()
|
||||||
|
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
|
||||||
|
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||||
|
if (this.alt) this.altDirty = true
|
||||||
|
else this.line += c
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'esc':
|
||||||
|
if (c === '[') this.mode = 'csi'
|
||||||
|
else if (c === ']') this.mode = 'osc'
|
||||||
|
else if (c === '(' || c === ')' || c === '#' || c === '%') this.mode = 'esc-skip1'
|
||||||
|
else this.mode = 'text' // single-character escape, consumed
|
||||||
|
break
|
||||||
|
case 'esc-skip1':
|
||||||
|
this.mode = 'text'
|
||||||
|
break
|
||||||
|
case 'csi':
|
||||||
|
if (c >= '@' && c <= '~') {
|
||||||
|
// the final byte is not part of `seq` — test the full sequence
|
||||||
|
const full = this.seq + c
|
||||||
|
if (ALT_ON.test(full)) {
|
||||||
|
this.alt = true
|
||||||
|
} else if (ALT_OFF.test(full)) {
|
||||||
|
this.alt = false
|
||||||
|
if (this.altDirty) {
|
||||||
|
this.altDirty = false
|
||||||
|
out += MARKER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.seq = ''
|
||||||
|
this.mode = 'text'
|
||||||
|
} else if (this.seq.length < 1024) {
|
||||||
|
this.seq += c
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'osc':
|
||||||
|
if (c === '\x07') this.mode = 'text'
|
||||||
|
else if (c === '\x1b') this.mode = 'osc-esc'
|
||||||
|
break
|
||||||
|
case 'osc-esc':
|
||||||
|
// ST is ESC \; a new ESC [ / ESC ] starts the next sequence
|
||||||
|
if (c === '\\') this.mode = 'text'
|
||||||
|
else if (c === '[') {
|
||||||
|
this.mode = 'csi'
|
||||||
|
this.seq = ''
|
||||||
|
} else if (c === ']') this.mode = 'osc'
|
||||||
|
else this.mode = 'text'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the pending partial line at log stop. */
|
||||||
|
flush(): string {
|
||||||
|
let out = ''
|
||||||
|
if (this.cr) {
|
||||||
|
this.cr = false
|
||||||
|
// A trailing \r at stop: treat as line ending rather than overwrite.
|
||||||
|
out += this.emitLine()
|
||||||
|
} else if (this.line && !this.alt) {
|
||||||
|
out += this.line
|
||||||
|
this.line = ''
|
||||||
|
}
|
||||||
|
if (this.altDirty) {
|
||||||
|
this.altDirty = false
|
||||||
|
out += MARKER
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete the current line: hand it out with a newline and reset. */
|
||||||
|
private emitLine(): string {
|
||||||
|
if (this.alt) {
|
||||||
|
if (this.line) this.altDirty = true
|
||||||
|
this.line = ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const out = this.line ? this.line + '\n' : '\n'
|
||||||
|
this.line = ''
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
MenuUnfoldOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined
|
||||||
ThunderboltOutlined
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { DockviewReact } from 'dockview-react'
|
import { DockviewReact } from 'dockview-react'
|
||||||
import type {
|
import type {
|
||||||
@@ -33,7 +32,6 @@ import { HostKeyModal } from './HostKeyModal'
|
|||||||
import { TerminalPanel } from './TerminalPanel'
|
import { TerminalPanel } from './TerminalPanel'
|
||||||
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
||||||
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
||||||
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
|
|
||||||
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
||||||
import { useSettingsStore } from '@renderer/settings/store'
|
import { useSettingsStore } from '@renderer/settings/store'
|
||||||
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
||||||
@@ -73,6 +71,18 @@ function nextTerminalTitle(): string {
|
|||||||
return `终端 ${titleSeqHolder.__otTitleSeq}`
|
return `终端 ${titleSeqHolder.__otTitleSeq}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift the counter past every "终端 N" currently on screen. The counter lives
|
||||||
|
* only in memory, so after a session restore / template apply it is behind the
|
||||||
|
* restored titles and the next new terminal duplicates one (`终端 2` twice).
|
||||||
|
*/
|
||||||
|
function syncTitleSeq(titles: Iterable<string | undefined>): void {
|
||||||
|
for (const title of titles) {
|
||||||
|
const n = title?.match(/^终端 (\d+)$/)?.[1]
|
||||||
|
if (n) titleSeqHolder.__otTitleSeq = Math.max(titleSeqHolder.__otTitleSeq ?? 0, Number(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
||||||
return (panel?.params as TerminalParams | undefined)?.sessionId
|
return (panel?.params as TerminalParams | undefined)?.sessionId
|
||||||
}
|
}
|
||||||
@@ -201,7 +211,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
|||||||
<div className="workspace-tabbar-actions">
|
<div className="workspace-tabbar-actions">
|
||||||
<AddTerminalGroupButton onClick={handleAdd} />
|
<AddTerminalGroupButton onClick={handleAdd} />
|
||||||
<BroadcastToggleButton />
|
<BroadcastToggleButton />
|
||||||
<QuickInputToggleButton />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -240,7 +249,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
/** Per-workspace active panel used as the split anchor. */
|
/** Per-workspace active panel used as the split anchor. */
|
||||||
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||||
const activeSshPanelRef = 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)
|
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||||
|
|
||||||
/** Sessions we already asked to kill — kill once, never twice. */
|
/** Sessions we already asked to kill — kill once, never twice. */
|
||||||
@@ -864,6 +873,18 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
|
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
|
||||||
await rebindSessionPanels(terminal, snapshot, true, renames)
|
await rebindSessionPanels(terminal, snapshot, true, renames)
|
||||||
await rebindSessionPanels(ssh, snapshot, true, renames)
|
await rebindSessionPanels(ssh, snapshot, true, renames)
|
||||||
|
const allPanels = [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]
|
||||||
|
syncTitleSeq(allPanels.map((p) => p.title))
|
||||||
|
// Retitle duplicates left over from the counter bug (the snapshot the bug
|
||||||
|
// wrote keeps colliding titles forever otherwise): first panel keeps the
|
||||||
|
// title, later ones get a fresh number past the restored maximum.
|
||||||
|
const seenTitles = new Set<string>()
|
||||||
|
for (const p of allPanels) {
|
||||||
|
const t = p.title ?? ''
|
||||||
|
if (!t) continue
|
||||||
|
if (seenTitles.has(t)) p.setTitle(nextTerminalTitle())
|
||||||
|
else seenTitles.add(t)
|
||||||
|
}
|
||||||
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
||||||
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
||||||
recountAll()
|
recountAll()
|
||||||
@@ -911,6 +932,9 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
// Fresh sessions for every restored terminal panel in both workspaces.
|
// Fresh sessions for every restored terminal panel in both workspaces.
|
||||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||||
|
syncTitleSeq(
|
||||||
|
[...(terminalApiRef.current?.panels ?? []), ...(sshApiRef.current?.panels ?? [])].map((p) => p.title)
|
||||||
|
)
|
||||||
|
|
||||||
// Old sessions are unreachable after the layout swap — kill them.
|
// Old sessions are unreachable after the layout swap — kill them.
|
||||||
for (const sid of previousSessions) killSession(sid)
|
for (const sid of previousSessions) killSession(sid)
|
||||||
@@ -961,13 +985,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 (
|
return (
|
||||||
<div className="workspace-root">
|
<div className="workspace-root">
|
||||||
<IconRail
|
<IconRail
|
||||||
@@ -1056,11 +1073,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
</div>
|
</div>
|
||||||
</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). */}
|
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
|
||||||
{requestedConn != null && (
|
{requestedConn != null && (
|
||||||
<ConnectFlow
|
<ConnectFlow
|
||||||
@@ -1206,25 +1218,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.
|
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
||||||
* Top: workspace actions; bottom: settings.
|
* Top: workspace actions; bottom: settings.
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ body.ssh-resizing .ssh-bottom-divider {
|
|||||||
color: var(--tab-accent-fg, #3fd06a);
|
color: var(--tab-accent-fg, #3fd06a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
|
/* ---- M6 tab-bar workspace actions (broadcast) ---- */
|
||||||
.workspace-tabbar-actions {
|
.workspace-tabbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -574,147 +574,6 @@ body.ssh-resizing .ssh-bottom-divider {
|
|||||||
user-select: none;
|
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) ---- */
|
/* ---- custom tab (title + close + context menu) ---- */
|
||||||
.workspace-terminal-tab {
|
.workspace-terminal-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in new issue
Block a user