@@ -424,29 +555,40 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
{loading && entries.length === 0 &&
加载中…
}
{!loading && entries.length === 0 &&
空目录
}
{entries.map((e) => (
-
setSelected(e)}
- onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
+ menu={menu}
+ trigger={['contextMenu']}
+ getPopupContainer={() => document.body}
+ onOpenChange={(open) => {
+ if (!open) setMenuEntry(null)
+ }}
>
- {e.isDir ? (
-
- ) : (
-
- )}
-
- {e.name}
-
-
- {e.mode ?? '----------'}
-
-
- {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
-
- {e.isDir ? '' : fmtSize(e.size)}
- {fmtTime(e.mtime)}
-
+
setSelected(e)}
+ onContextMenu={() => {
+ setSelected(e)
+ setMenuEntry(e)
+ }}
+ onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
+ >
+ {e.isDir ? : }
+
+ {e.name}
+
+
+ {e.mode ?? '----------'}
+
+
+ {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
+
+
+ {e.isDir ? '' : fmtSize(e.size)}
+
+ {fmtTime(e.mtime)}
+
+
))}
)}
diff --git a/src/renderer/src/sftp/sftp.css b/src/renderer/src/sftp/sftp.css
index d543dc9..7382096 100644
--- a/src/renderer/src/sftp/sftp.css
+++ b/src/renderer/src/sftp/sftp.css
@@ -39,12 +39,19 @@
overflow-y: auto;
}
+/* Right-click menu wrapper: antd wraps each row in a span by default;
+ force block so rows keep full-width hover/selection and normal flow. */
+.sftp-list .ant-dropdown-trigger {
+ display: block;
+}
+
.sftp-row {
- display: flex;
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px;
align-items: center;
- gap: 6px;
+ column-gap: 8px;
padding: 3px 8px;
- font-size: 12px;
+ font-size: 13px;
color: var(--chrome-fg);
cursor: pointer;
}
@@ -58,7 +65,9 @@
}
.sftp-icon {
+ display: inline-flex;
color: #8a8a8a;
+ font-size: 14px;
}
.sftp-icon-dir {
@@ -66,26 +75,23 @@
}
.sftp-name {
- flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ font-size: 13.5px;
}
.sftp-mode {
- flex: none;
- width: 90px;
text-align: center;
color: #9a9a9a;
font-family: Consolas, 'Courier New', monospace;
font-size: 11px;
white-space: nowrap;
+ overflow: hidden;
}
.sftp-owner {
- flex: none;
- width: 44px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
@@ -94,19 +100,19 @@
}
.sftp-size {
- flex: none;
- width: 52px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
}
.sftp-time {
- flex: none;
- width: 78px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
}
.sftp-empty,
diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx
index bcece0b..269d814 100644
--- a/src/renderer/src/terminal/TerminalView.tsx
+++ b/src/renderer/src/terminal/TerminalView.tsx
@@ -69,6 +69,15 @@ interface SearchBarProps {
result: string
}
+/** M5: cap for the floating completion popup height in px. */
+const SUGGEST_POPUP_H = 200
+
+/** M5: estimated popup height for a given number of suggestion rows. */
+function estimatePopupHeight(itemCount: number): number {
+ // item ≈ 21px, hint row ≈ 18px, popup v-padding + gaps ≈ 10px.
+ return Math.min(SUGGEST_POPUP_H, Math.max(70, itemCount * 22 + 28))
+}
+
function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element {
return (
@@ -100,13 +109,40 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
* buffer is empty (some control data only). Enter/backspace/espace/ctrl-c are
* handled here; escape sequences (\x1b) and the leading non-printable control
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer.
+ *
+ * `rewrite` flags a completion acceptance that just rewrote the shell line (a
+ * Ctrl+U kill + full-command write). The very next onData chunk is the shell's
+ * redraw echoing the accepted line, so instead of appending onto the stale
+ * pre-accept buffer we *replace* it with what the pty actually echoed. This is
+ * the echo-suppression that keeps the local buffer in sync with the shell and
+ * prevents the accepted text from being duplicated.
*/
function stepLineBuffer(
outBuf: React.MutableRefObject,
- data: string
+ data: string,
+ rewrite: boolean
): { line: string; submit?: string } {
const b = outBuf.current
if (!data) return { line: b }
+ // Completion-rewrite redraw: reset to this chunk's visible text verbatim. For
+ // a shell like PowerShell the redraw is a leading escape/control sequence
+ // + the line text; strip leading control bytes so only the echoed command
+ // enters the buffer (matching how a normal chunk is captured). Any trailing
+ // text on the chunk is appended as usual.
+ if (rewrite) {
+ let i = 0
+ while ((data[i] === '\x1b' && (data[i + 1] === '[' || data[i + 1] === '(')) || data[i] === '\r' || data[i] === '\n') {
+ i += data[i] === '\x1b' ? 2 : 1
+ }
+ const visible = data.slice(i)
+ if (visible) {
+ outBuf.current = visible
+ return { line: visible }
+ }
+ // Only control output (e.g. the kill sequence handling) — treat as a reset.
+ outBuf.current = ''
+ return { line: '' }
+ }
// A chunk that begins with escape/control sequences (e.g. the terminal's
// clear-line) only removes buffer content; never appends.
const first = data[0]
@@ -142,14 +178,34 @@ function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] {
return out
}
-/** M5: send only the part of `full` that isn't already in the pty buffer. */
+/**
+ * [M5] Accept a completion by forcing the pty to rewrite the whole line rather
+ * than appending only the missing tail.
+ *
+ * The previous implementation wrote just `full.slice(buf.length)` when the
+ * shell line buffer (`buf`) was a prefix of the target (`full`). That delta
+ * approach is what lost characters: `lineBufRef` is accumulated locally from
+ * user keystrokes and races the shell's real line buffer. Whenever the shell
+ * redraws the line (e.g. it emits a clear-line/control sequence, or the pty
+ * echoes control output back to us) the local buffer drifts out of sync with
+ * the shell, and the next keystroke overwrites the completion we just wrote.
+ *
+ * Rewriting the whole line removes that shared-mutable-buffer dependence:
+ * 1. `\x15` (Ctrl+U) kills the current line. bash, zsh, fish, and Windows
+ * PowerShell's PSReadLine all honor it, and — unlike `\x03` (Ctrl+C) —
+ * it leaves no caret and does not start a fresh prompt, so the shell stays
+ * on the same line ready for the replacement text. (A control-sequence
+ * fallback of `\x1b[D` repeated N times + `\x1b[K` would also work but is
+ * noisier and shell-specific; Ctrl+U is simpler and reliably supported.)
+ * 2. We then write `full`; the pty echoes it into the session buffer.
+ *
+ * `buf` is retained purely for call-site symmetry with the old signature; the
+ * rewrite always sends the Ctrl+U + full command. Whatever the local buffer
+ * previously held is irrelevant to the pty after the kill.
+ */
function differenceWrite(sessionId: string, full: string, buf: string): void {
- if (full.startsWith(buf) && buf) {
- writeBroadcast(sessionId, full.slice(buf.length))
- } else {
- writeBroadcast(sessionId, '\x03')
- writeBroadcast(sessionId, full)
- }
+ writeBroadcast(sessionId, '\x15')
+ writeBroadcast(sessionId, full)
}
export const TerminalView: ForwardRefExoticComponent }> =
@@ -172,6 +228,13 @@ export const TerminalView: ForwardRefExoticComponent(null)
// M5: per-session line-capture buffer + completion candidate cache.
const lineBufRef = useRef('')
+ // M5: when a completion acceptance rewrites the shell line (Ctrl+U + full
+ // command via writeBroadcast in `differenceWrite`), the shell answers with a
+ // redraw that echoes `full`. If that echo were folded into the local buffer
+ // on top of the pre-accept text it would double the line. `diffRewriteRef`
+ // flags that the *next* onData chunk is that redraw output, so stepLineBuffer
+ // replaces the buffer with the freshly-echoed line instead of appending.
+ const diffRewriteRef = useRef(false)
const completionCacheRef = useRef([])
const completionKeyRef = useRef('')
const recordingRef = useRef(false)
@@ -179,6 +242,11 @@ export const TerminalView: ForwardRefExoticComponent([])
const selIndexRef = useRef(0)
+ // Absolute-positioning hook for the completion popup (the box that wraps the
+ // xterm surface), so popup coordinates start at the terminal content area.
+ const suggestHookRef = useRef(null)
+ // M5: absolute position (view-relative) of the floating completion popup.
+ const [popupPos, setPopupPos] = useState<{ left: number; top: number; flip: boolean } | null>(null)
// Compile keyword-highlight rules once per rules change; keep them in a ref so
// the (stable, single-construction) pty/term subscriptions always read the
@@ -361,8 +429,12 @@ export const TerminalView: ForwardRefExoticComponent {
const cmd = selected.command
- const buf = lineBufRef.current
- differenceWrite(sessionId, cmd, buf)
+ // Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a
+ // delta — the pty redraw, not our local buffer, becomes the source of
+ // truth for the line (see differenceWrite). The redraw trigger sets the
+ // rewrite flag so onData resets the line buffer to the echoed line.
+ differenceWrite(sessionId, cmd, lineBufRef.current)
+ diffRewriteRef.current = true
lineBufRef.current = cmd
suggestionsRef.current = []
selIndexRef.current = 0
@@ -422,7 +494,12 @@ export const TerminalView: ForwardRefExoticComponent {
+ if (termRef.current === term && suggestionsRef.current.length > 0) placeSuggestPopup()
+ })
const searchAddon = new SearchAddon()
searchAddon.onDidChangeResults((ev) => {
@@ -519,8 +601,12 @@ export const TerminalView: ForwardRefExoticComponent {
if (!deadRef.current) writeBroadcast(sessionId, data)
+ // M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
+ // now so this redraw chunk replaces (not appends to) the line buffer.
+ const consumeRewrite = diffRewriteRef.current
+ diffRewriteRef.current = false
// M5: line capture → recordCommand + inline completion overlay.
- const step = stepLineBuffer(lineBufRef, data)
+ const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined)
}
@@ -549,8 +635,17 @@ export const TerminalView: ForwardRefExoticComponent undefined)
+ }),
+ // Keep the floating completion popup pinned to wherever the text cursor
+ // currently is (repositioned only while suggestions are visible; opening
+ // the popup also places it once in the effect further below).
+ term.onCursorMove(() => {
+ if (suggestionsRef.current.length > 0) placeSuggestPopup()
})
]
+ // [M5] Placement for a visible popup — one measurement per popup opening,
+ // so the overlay appears before a subsequent cursor-move event fires.
+ if (suggestionsRef.current.length > 0) placeSuggestPopup()
// Live data is queued until the replay lands so output keeps its order.
let replayDone = false
const pending: string[] = []
@@ -666,6 +761,53 @@ export const TerminalView: ForwardRefExoticComponent ['terminal-view', className].filter(Boolean).join(' '), [className])
+ /**
+ * [M5] Compute and store the floating popup position so it hugs the text
+ * cursor. Called whenever the suggestions become visible (the cursor does not
+ * move while a completion is open, so one placement per popup is enough) and
+ * on every cursor move while the popup is open. Coordinates are relative to
+ * `suggestHookRef` (the container that wraps the xterm surface), so `top`
+ * starts from the terminal content row area.
+ */
+ /**
+ * [M5] Compute and store the floating popup position so it hugs the text
+ * cursor. Called whenever the suggestions become visible (the cursor does not
+ * move while a completion is open, so one placement per popup is enough) and
+ * on every cursor move while the popup is open. Coordinates are relative to
+ * `suggestHookRef` (the container that wraps the xterm surface), so `top`
+ * starts from the terminal content row area.
+ */
+ const placeSuggestPopup = useCallback((): void => {
+ const term = termRef.current
+ if (!term) return
+ // xterm 6 internal API (`term._core._renderService.dimensions.css.cell`)
+ // may change across versions — keep the guarded fallback to a
+ // font-size-based cell estimate.
+ const ctx = term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } } }
+ const dim = ctx._core?._renderService?.dimensions?.css?.cell
+ const fontSize = term.options.fontSize ?? 14
+ const lineHeight = term.options.lineHeight ?? 1
+ const cellWidth = dim && dim.width > 0 ? dim.width : fontSize * 0.6
+ const cellHeight = dim && dim.height > 0 ? dim.height : fontSize * lineHeight
+ const padLeft = 4
+ const cursorX = term.buffer.active.cursorX
+ const cursorY = term.buffer.active.cursorY
+ const left = Math.round(cursorX * cellWidth + padLeft)
+ const hook = suggestHookRef.current
+ const roof = hook ? hook.getBoundingClientRect().height : 0
+ const hookWidth = hook ? hook.getBoundingClientRect().width : 0
+ // Keep a right-side margin so a wide popup (min 240px) never overflows the
+ // terminal container — e.g. a narrow split pane with the cursor mid-text.
+ const leftClamped = Math.min(Math.max(left, 0), Math.max(0, hookWidth - 240))
+ // Below the cursor row by default; only flip above when there is not enough
+ // room under the cursor (uses a height estimate keyed off live count).
+ const popH = estimatePopupHeight(suggestionsRef.current.length)
+ const belowTop = (cursorY + 1) * cellHeight
+ const flip = belowTop > Math.max(roof - popH, 100)
+ const top = Math.round(flip ? Math.max(0, cursorY * cellHeight - popH) : belowTop)
+ setPopupPos({ left: leftClamped, top, flip })
+ }, [])
+
const onSearchChange = useCallback((value: string) => {
setSearchTerm(value)
searchTermRef.current = value
@@ -707,29 +849,6 @@ export const TerminalView: ForwardRefExoticComponent
- {suggestions.length > 0 && (
-
e.preventDefault()}
- onClick={(e) => e.stopPropagation()}
- >
-
- {suggestions.map((s, i) => (
-
- ))}
-
-
↑/↓ 选择 · Tab 接受 · Esc 关闭
-
- )}
{paste !== null && !dead && (
检测到多行/长文本粘贴
@@ -741,13 +860,42 @@ export const TerminalView: ForwardRefExoticComponent
)}
-
- {dead && (
-
-
进程已退出 (代码 {exitCode})
-
+ {/* Relative-positioned wrapper around the xterm surface: the suggestion
+ popup anchors to this box, so its coordinates start at the terminal
+ content rows (the search bar above never offsets the top). */}
+
+ {popupPos !== null && suggestions.length > 0 && (
+
e.preventDefault()}
+ onClick={(e) => e.stopPropagation()}
+ >
+
+ {suggestions.map((s, i) => (
+
+ ))}
+
+
↑/↓ 选择 · Tab 接受 · Esc 关闭
)}
+
+ {dead && (
+
+
进程已退出 (代码 {exitCode})
+
+
+ )}
+
)
diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css
index 3195aac..223ca92 100644
--- a/src/renderer/src/terminal/terminal.css
+++ b/src/renderer/src/terminal/terminal.css
@@ -108,11 +108,25 @@
.term-recbar {
position: absolute;
top: 4px;
- left: 4px;
+ right: 14px;
z-index: 4;
display: flex;
align-items: center;
gap: 2px;
+ padding: 2px;
+ border-radius: 6px;
+ background: rgba(24, 26, 31, 0.85);
+ border: 1px solid var(--chrome-border);
+ /* Hidden until the terminal is hovered — it must not sit over the output. */
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s ease;
+}
+
+.terminal-view:hover .term-recbar,
+.term-recbar:focus-within {
+ opacity: 1;
+ pointer-events: auto;
}
.term-rec-btn {
@@ -139,22 +153,46 @@
color: var(--chrome-accent);
}
-/* ---- M5 inline command-completion overlay ---- */
+/* While a log is recording, keep the bar visible so the state is obvious. */
+.term-recbar:has(.term-rec-btn.is-recording) {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* ---- M5 inline command-completion floating popup ----
+ Absolutely positioned inside .term-suggest-hook (the box that wraps the
+ terminal surface), so it tracks the text cursor and is never clipped by the
+ terminal-view's own overflow. The list scrolls internally when too tall. */
+.term-suggest-hook {
+ position: relative;
+ flex: 1 1 0;
+ min-height: 0;
+ min-width: 0;
+}
+
.term-suggestbar {
- flex: none;
+ position: absolute;
+ z-index: 6;
display: flex;
flex-direction: column;
- gap: 2px;
- padding: 4px 6px;
- background: var(--chrome-hover);
- border-bottom: 1px solid var(--chrome-border);
+ min-width: 240px;
+ max-width: 420px;
+ max-height: 200px;
+ padding: 5px 6px 6px;
+ overflow-y: auto;
+ background: var(--chrome-bg);
+ border: 1px solid var(--chrome-border);
+ border-radius: 8px;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
user-select: none;
+ box-sizing: border-box;
}
.term-suggest-list {
display: flex;
flex-direction: column;
gap: 1px;
+ flex: 0 0 auto;
}
.term-suggest-item {
@@ -199,9 +237,13 @@
}
.term-suggest-hint {
+ flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
opacity: 0.9;
+ padding-top: 4px;
+ line-height: 13px;
+ white-space: nowrap;
}
/* ---- paste risk confirm bar ---- */
diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx
new file mode 100644
index 0000000..6d2c834
--- /dev/null
+++ b/src/renderer/src/workspace/SshBottomPanel.tsx
@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
+
+import { FilePanel } from '../sftp/FilePanel'
+
+export interface SshBottomPanelProps {
+ sessionId: string
+ /** The terminal surface rendered in the flexible top region. */
+ terminal: ReactNode
+}
+
+/** File browser height in px — clamped to [120, 60% of container height]. */
+const MIN_HEIGHT = 120
+
+export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
+ /** Current file-browser height in px. */
+ const [filesHeight, setFilesHeight] = useState
(220)
+ /** Container height captured while a drag is in flight. */
+ const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null)
+
+ const onDividerMouseDown = useCallback((e: React.MouseEvent): void => {
+ if (e.button !== 0) return
+ const container = (e.currentTarget.parentElement as HTMLElement | null) ?? null
+ if (!container) return
+ dragInfoRef.current = {
+ startY: e.clientY,
+ startHeight: filesHeight,
+ containerH: container.clientHeight
+ }
+ e.preventDefault()
+ }, [filesHeight])
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent): void => {
+ const info = dragInfoRef.current
+ if (!info) return
+ const delta = e.clientY - info.startY
+ const max = Math.round(info.containerH * 0.6)
+ setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta)))
+ }
+ const onUp = (): void => {
+ dragInfoRef.current = null
+ }
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ return () => {
+ window.removeEventListener('mousemove', onMove)
+ window.removeEventListener('mouseup', onUp)
+ }
+ }, [])
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/renderer/src/workspace/SshHostBadge.tsx b/src/renderer/src/workspace/SshHostBadge.tsx
new file mode 100644
index 0000000..adb6411
--- /dev/null
+++ b/src/renderer/src/workspace/SshHostBadge.tsx
@@ -0,0 +1,17 @@
+/**
+ * Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`)
+ * on an SSH terminal tab. Host labels are often long, so the display string is
+ * truncated with an ellipsis rather than wrapped; the full label is always
+ * available via the native `title` tooltip and the tab-level tooltip.
+ */
+export function SshHostBadge({ label }: { label: string }): React.JSX.Element {
+ return (
+
+ {label}
+
+ )
+}
\ No newline at end of file
diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx
index 734acdf..4d6258a 100644
--- a/src/renderer/src/workspace/TerminalPanel.tsx
+++ b/src/renderer/src/workspace/TerminalPanel.tsx
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import type { IDockviewPanelProps } from 'dockview-react'
-import { Button, Tabs } from 'antd'
-import { AreaChartOutlined, FolderOpenOutlined } from '@ant-design/icons'
+import { Button } from 'antd'
+import { AreaChartOutlined } from '@ant-design/icons'
import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
-import { FilePanel } from '../sftp/FilePanel'
+import { SshBottomPanel } from './SshBottomPanel'
import { useBroadcastStore } from './broadcastStore'
import type { TerminalParams } from './Workspace'
@@ -28,15 +28,16 @@ export type TerminalPanelProps = IDockviewPanelProps & TerminalP
* already-exited process.
*
* For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with
- * [系统监控 | 文件] tabs is docked next to the terminal. Local sessions render
- * terminal only.
+ * [系统监控] is docked next to the terminal, and a resizable file browser
+ * (SshBottomPanel) sits below the terminal. Local sessions render terminal
+ * only.
*/
export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element {
const sessionId = params.sessionId ?? ''
const sessionKind = params.sessionKind
const isSsh = sessionKind === 'ssh'
const closedRef = useRef(false)
- /** Right sidebar visibility for ssh sessions — expanded by default. */
+ /** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */
const [sideOpen, setSideOpen] = useState(true)
// M6 broadcast: keep the session in the broadcast registry while mounted so
@@ -55,6 +56,19 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
return () => disposable.dispose()
}, [api, sessionId, isSsh])
+ const terminal = (
+ {
+ if (closedRef.current) return
+ closedRef.current = true
+ onSessionDead?.(sessionId)
+ api.close()
+ }}
+ />
+ )
+
return (
{isSsh && (
@@ -62,41 +76,17 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
- icon={sideOpen ?
:
}
+ icon={
}
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
-
{
- if (closedRef.current) return
- closedRef.current = true
- onSessionDead?.(sessionId)
- api.close()
- }}
- />
+ {isSsh ? : terminal}
{isSsh && sideOpen && (
-
- },
- {
- key: 'files',
- label: '文件',
- children:
- }
- ]}
- />
+
)}
diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx
index 367c482..3c587aa 100644
--- a/src/renderer/src/workspace/TerminalTab.tsx
+++ b/src/renderer/src/workspace/TerminalTab.tsx
@@ -5,6 +5,7 @@ import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
+import { SshHostBadge } from './SshHostBadge'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -29,7 +30,13 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const selfIndex = panel.panels.findIndex((p) => p.api === api)
const isSsh = params?.sessionKind === 'ssh'
- // M6 broadcast: show a dot on tabs that are current broadcast targets.
+ const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined
+ const tooltip = isSsh
+ ? hostLabel && hostLabel !== title
+ ? `${title} — ${hostLabel}`
+ : (hostLabel || title)
+ : '本地终端'
+ // M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
// Keep the tab title live (dockview re-renders tab components only on
@@ -68,15 +75,21 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
return (
-
+
{isSsh &&
}
{title}
+ {isSsh && hostLabel &&
}
{isBroadcastTarget && (
+ >
+ 播
+
)}