fix ssh auth and transfer modal state
This commit is contained in:
@@ -6,13 +6,17 @@ export default function Modal({
|
||||
children,
|
||||
footer,
|
||||
maxWidth = 'max-w-3xl',
|
||||
open = true,
|
||||
}: {
|
||||
title: string
|
||||
onClose?: () => void
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
maxWidth?: string
|
||||
open?: boolean
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-slate-700 bg-slate-900 ${maxWidth}`}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
FileUp,
|
||||
@@ -68,6 +68,35 @@ export default function TransferCenterModal({
|
||||
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
|
||||
const [showBrowser, setShowBrowser] = useState(false)
|
||||
|
||||
// ── sync remoteSourceId when connections or statuses change ──
|
||||
useEffect(() => {
|
||||
if (connections.length === 0) return
|
||||
setRemoteSourceId((prev) => {
|
||||
if (prev > 0 && connections.some((c) => c.id === prev)) {
|
||||
// Keep current selection if it's online or status is still loading
|
||||
const st = connectionStatuses[prev]
|
||||
if (st === 'online' || !st) return prev
|
||||
// current source is offline/unknown — re-evaluate
|
||||
const firstOnline = connections.find((c) => connectionStatuses[c.id] === 'online')
|
||||
return firstOnline?.id ?? prev
|
||||
}
|
||||
return connections.find((c) => connectionStatuses[c.id] === 'online')?.id ?? connections[0]?.id ?? 0
|
||||
})
|
||||
}, [connections, connectionStatuses])
|
||||
|
||||
// ── derived state ──
|
||||
const selectedRemoteSource = useMemo(
|
||||
() => connections.find((c) => c.id === remoteSourceId) ?? null,
|
||||
[connections, remoteSourceId],
|
||||
)
|
||||
const isRemoteSourceValid = remoteSourceId > 0 && selectedRemoteSource !== null
|
||||
const isRemoteSourceOnline = isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'online'
|
||||
|
||||
// In remote-distribution mode, exclude the source server from the effective target list
|
||||
const effectiveTargetIds = tab === 'remote' ? targetIds.filter((id) => id !== remoteSourceId) : targetIds
|
||||
const remoteStartDisabled =
|
||||
!isRemoteSourceValid || !isRemoteSourceOnline || effectiveTargetIds.length === 0 || !remoteSourcePath.trim()
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
||||
@@ -130,7 +159,8 @@ export default function TransferCenterModal({
|
||||
}
|
||||
|
||||
async function handleStartRemote() {
|
||||
if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return
|
||||
const targets = effectiveTargetIds
|
||||
if (!isRemoteSourceValid || !isRemoteSourceOnline || targets.length === 0 || !remoteSourcePath.trim()) return
|
||||
const groupId = String(Date.now())
|
||||
const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim()
|
||||
const group: TransferTaskGroup = {
|
||||
@@ -140,7 +170,7 @@ export default function TransferCenterModal({
|
||||
status: 'running',
|
||||
progress: 0,
|
||||
createdAt: new Date().toLocaleTimeString(),
|
||||
items: targetIds.map((id) => ({
|
||||
items: targets.map((id) => ({
|
||||
id: `${groupId}-${id}`,
|
||||
label: connections.find((item) => item.id === id)?.name || String(id),
|
||||
status: 'queued',
|
||||
@@ -150,7 +180,7 @@ export default function TransferCenterModal({
|
||||
}
|
||||
onTasksChange((prev) => [group, ...prev])
|
||||
|
||||
targetIds.forEach(async (targetId) => {
|
||||
targets.forEach(async (targetId) => {
|
||||
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
|
||||
const taskId = response.data.taskId
|
||||
updateTaskGroup(groupId, (current) => ({
|
||||
@@ -339,9 +369,9 @@ export default function TransferCenterModal({
|
||||
</div>
|
||||
<select
|
||||
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
|
||||
connectionStatuses[remoteSourceId] === 'online'
|
||||
isRemoteSourceOnline
|
||||
? 'border-emerald-700'
|
||||
: connectionStatuses[remoteSourceId] === 'offline'
|
||||
: isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'offline'
|
||||
? 'border-red-800'
|
||||
: 'border-slate-700'
|
||||
}`}
|
||||
@@ -351,6 +381,11 @@ export default function TransferCenterModal({
|
||||
setShowBrowser(false)
|
||||
}}
|
||||
>
|
||||
{!isRemoteSourceValid && (
|
||||
<option value={0} disabled>
|
||||
请选择源服务器
|
||||
</option>
|
||||
)}
|
||||
{connections.map((server) => {
|
||||
const st = connectionStatuses[server.id]
|
||||
const isOnline = st === 'online'
|
||||
@@ -414,13 +449,24 @@ export default function TransferCenterModal({
|
||||
<span className="text-sm text-slate-300">目标服务器</span>
|
||||
<button
|
||||
className="text-xs text-blue-400"
|
||||
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
|
||||
onClick={() =>
|
||||
setTargetIds(
|
||||
tab === 'remote'
|
||||
? connections
|
||||
.filter((c) => c.id !== remoteSourceId && connectionStatuses[c.id] === 'online')
|
||||
.map((c) => c.id)
|
||||
: connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
全选在线
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
||||
{connections.map((server) => {
|
||||
{(tab === 'remote'
|
||||
? connections.filter((c) => c.id !== remoteSourceId)
|
||||
: connections
|
||||
).map((server) => {
|
||||
const st = connectionStatuses[server.id]
|
||||
const isOnline = st === 'online'
|
||||
return (
|
||||
@@ -450,21 +496,23 @@ export default function TransferCenterModal({
|
||||
|
||||
<button
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||||
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
|
||||
remoteStartDisabled
|
||||
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-500'
|
||||
}`}
|
||||
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
|
||||
disabled={remoteStartDisabled}
|
||||
onClick={() => void handleStartRemote()}
|
||||
>
|
||||
<Zap size={18} fill="currentColor" />
|
||||
{!remoteSourceId
|
||||
{!isRemoteSourceValid || !remoteSourceId
|
||||
? '请选择源服务器'
|
||||
: !remoteSourcePath.trim()
|
||||
? '请填写源路径'
|
||||
: targetIds.length === 0
|
||||
? '请选择目标服务器'
|
||||
: '跨服同步分发'}
|
||||
: !isRemoteSourceOnline
|
||||
? '源服务器未在线'
|
||||
: !remoteSourcePath.trim()
|
||||
? '请填写源路径'
|
||||
: effectiveTargetIds.length === 0
|
||||
? '请选择目标服务器'
|
||||
: '跨服同步分发'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { startTransition, useEffect, useMemo, useReducer, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
Command,
|
||||
@@ -104,6 +104,131 @@ function omitConnectionIdsFromRecord<T>(record: Record<number, T>, deletedConnec
|
||||
) as Record<number, T>
|
||||
}
|
||||
|
||||
function createTabId(connectionId: number) {
|
||||
return `${connectionId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: WorkspaceTab[]
|
||||
currentTabKey: string | null
|
||||
terminalStatuses: Record<string, TerminalConnectionStatus>
|
||||
}
|
||||
|
||||
type TabAction =
|
||||
| { type: 'ACTIVATE_TAB'; tabId: string }
|
||||
| { type: 'OPEN_CONNECTION'; connection: Connection; tabId: string }
|
||||
| { type: 'CLOSE_TAB'; tabId: string }
|
||||
| { type: 'CLOSE_ALL' }
|
||||
| { type: 'DUPLICATE_TAB'; tabId: string; newTabId: string }
|
||||
| { type: 'UPDATE_CONNECTION'; connectionId: number; name: string; connection: Connection }
|
||||
| { type: 'REMOVE_CONNECTIONS'; connectionIds: Set<number> }
|
||||
| { type: 'SET_TERMINAL_STATUS'; tabId: string; status: TerminalConnectionStatus }
|
||||
|
||||
const initialTabState: TabState = {
|
||||
tabs: [],
|
||||
currentTabKey: null,
|
||||
terminalStatuses: {},
|
||||
}
|
||||
|
||||
function tabReducer(state: TabState, action: TabAction): TabState {
|
||||
switch (action.type) {
|
||||
case 'ACTIVATE_TAB': {
|
||||
if (state.currentTabKey === action.tabId) return state
|
||||
return { ...state, currentTabKey: action.tabId }
|
||||
}
|
||||
case 'OPEN_CONNECTION': {
|
||||
const existing = state.tabs.find((tab) => tab.connection.id === action.connection.id)
|
||||
if (existing) {
|
||||
return {
|
||||
...state,
|
||||
currentTabKey: existing.tabId,
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.tabId === existing.tabId
|
||||
? { ...tab, name: action.connection.name, connection: action.connection }
|
||||
: tab,
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
tabs: [...state.tabs, { tabId: action.tabId, name: action.connection.name, connection: action.connection }],
|
||||
currentTabKey: action.tabId,
|
||||
terminalStatuses: { ...state.terminalStatuses, [action.tabId]: 'connecting' },
|
||||
}
|
||||
}
|
||||
case 'CLOSE_TAB': {
|
||||
const next = state.tabs.filter((tab) => tab.tabId !== action.tabId)
|
||||
const nextStatuses = { ...state.terminalStatuses }
|
||||
delete nextStatuses[action.tabId]
|
||||
return {
|
||||
tabs: next,
|
||||
currentTabKey:
|
||||
state.currentTabKey === action.tabId
|
||||
? next[next.length - 1]?.tabId ?? null
|
||||
: state.currentTabKey,
|
||||
terminalStatuses: nextStatuses,
|
||||
}
|
||||
}
|
||||
case 'CLOSE_ALL':
|
||||
return { tabs: [], currentTabKey: null, terminalStatuses: {} }
|
||||
case 'DUPLICATE_TAB': {
|
||||
const idx = state.tabs.findIndex((tab) => tab.tabId === action.tabId)
|
||||
if (idx === -1) return state
|
||||
const source = state.tabs[idx]
|
||||
const newTab: WorkspaceTab = {
|
||||
tabId: action.newTabId,
|
||||
name: source.name,
|
||||
connection: source.connection,
|
||||
}
|
||||
const next = [...state.tabs]
|
||||
next.splice(idx + 1, 0, newTab)
|
||||
return {
|
||||
tabs: next,
|
||||
currentTabKey: action.newTabId,
|
||||
terminalStatuses: { ...state.terminalStatuses, [action.newTabId]: 'connecting' },
|
||||
}
|
||||
}
|
||||
case 'UPDATE_CONNECTION': {
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.connection.id === action.connectionId
|
||||
? { ...tab, name: action.name, connection: action.connection }
|
||||
: tab,
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'REMOVE_CONNECTIONS': {
|
||||
const remaining = state.tabs.filter((tab) => !action.connectionIds.has(tab.connection.id))
|
||||
const removedTabIds = new Set(
|
||||
state.tabs
|
||||
.filter((tab) => action.connectionIds.has(tab.connection.id))
|
||||
.map((tab) => tab.tabId),
|
||||
)
|
||||
const nextStatuses = { ...state.terminalStatuses }
|
||||
for (const tabId of removedTabIds) {
|
||||
delete nextStatuses[tabId]
|
||||
}
|
||||
return {
|
||||
tabs: remaining,
|
||||
currentTabKey:
|
||||
state.currentTabKey != null && removedTabIds.has(state.currentTabKey)
|
||||
? remaining[remaining.length - 1]?.tabId ?? null
|
||||
: state.currentTabKey,
|
||||
terminalStatuses: nextStatuses,
|
||||
}
|
||||
}
|
||||
case 'SET_TERMINAL_STATUS': {
|
||||
if (state.terminalStatuses[action.tabId] === action.status) return state
|
||||
return {
|
||||
...state,
|
||||
terminalStatuses: { ...state.terminalStatuses, [action.tabId]: action.status },
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorkspacePage({
|
||||
initialTool,
|
||||
onLogout,
|
||||
@@ -117,8 +242,8 @@ export default function WorkspacePage({
|
||||
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
||||
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
|
||||
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
|
||||
const [tabState, dispatchTab] = useReducer(tabReducer, initialTabState)
|
||||
const { tabs, currentTabKey, terminalStatuses } = tabState
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
||||
@@ -135,9 +260,13 @@ export default function WorkspacePage({
|
||||
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
|
||||
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
|
||||
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<Record<number, TerminalConnectionStatus>>({})
|
||||
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
|
||||
const [tabContextMenu, setTabContextMenu] = useState({ visible: false, x: 0, y: 0 })
|
||||
const [tabContextMenu, setTabContextMenu] = useState<{ visible: boolean; x: number; y: number; targetTabId: string | null }>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
targetTabId: null,
|
||||
})
|
||||
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
||||
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
||||
'ssh-manager.terminal-font-family',
|
||||
@@ -200,9 +329,9 @@ export default function WorkspacePage({
|
||||
|
||||
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
||||
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
|
||||
const openConnectionIds = useMemo(() => tabs.map((tab) => tab.id), [tabs])
|
||||
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
|
||||
const activeTerminalStatus = activeConnection ? terminalStatuses[activeConnection.id] ?? 'connecting' : 'idle'
|
||||
const openConnectionIds = useMemo(() => [...new Set(tabs.map((tab) => tab.connection.id))], [tabs])
|
||||
const activeConnection = tabs.find((tab) => tab.tabId === currentTabKey)?.connection ?? null
|
||||
const activeTerminalStatus = activeConnection ? terminalStatuses[currentTabKey ?? ''] ?? 'connecting' : 'idle'
|
||||
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
|
||||
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
|
||||
const connectionModalFolderId = useMemo(
|
||||
@@ -328,17 +457,7 @@ export default function WorkspacePage({
|
||||
|
||||
function openConnection(connection: Connection, nodeId?: string | null) {
|
||||
startTransition(() => {
|
||||
setTerminalStatuses((prev) => (prev[connection.id] ? prev : { ...prev, [connection.id]: 'connecting' }))
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((tab) => tab.id === connection.id)
|
||||
if (existing) {
|
||||
return prev.map((tab) =>
|
||||
tab.id === connection.id ? { ...tab, name: connection.name, connection } : tab,
|
||||
)
|
||||
}
|
||||
return [...prev, { id: connection.id, name: connection.name, connection }]
|
||||
})
|
||||
setCurrentTabId(connection.id)
|
||||
dispatchTab({ type: 'OPEN_CONNECTION', connection, tabId: createTabId(connection.id) })
|
||||
setSelectedFiles([])
|
||||
if (nodeId) {
|
||||
setSelectedNodeId(nodeId)
|
||||
@@ -419,17 +538,10 @@ export default function WorkspacePage({
|
||||
|
||||
const deletedConnectionIds = new Set(connectionIds)
|
||||
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((tab) => !deletedConnectionIds.has(tab.id))
|
||||
setCurrentTabId((current) =>
|
||||
current != null && deletedConnectionIds.has(current) ? next[next.length - 1]?.id ?? null : current,
|
||||
)
|
||||
return next
|
||||
})
|
||||
dispatchTab({ type: 'REMOVE_CONNECTIONS', connectionIds: deletedConnectionIds })
|
||||
setSelectedFiles([])
|
||||
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
setTerminalStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
}
|
||||
|
||||
async function handleEditTreeItem() {
|
||||
@@ -512,9 +624,12 @@ export default function WorkspacePage({
|
||||
|
||||
if (connectionToEdit) {
|
||||
setSelectedNodeId(nodeId)
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.id === nextConnection.id ? { ...tab, name: nextConnection.name, connection: nextConnection } : tab)),
|
||||
)
|
||||
dispatchTab({
|
||||
type: 'UPDATE_CONNECTION',
|
||||
connectionId: nextConnection.id,
|
||||
name: nextConnection.name,
|
||||
connection: nextConnection,
|
||||
})
|
||||
setConnectionStatusDetails((prev) =>
|
||||
prev[nextConnection.id]
|
||||
? {
|
||||
@@ -532,27 +647,24 @@ export default function WorkspacePage({
|
||||
openConnection(nextConnection, nodeId)
|
||||
}
|
||||
|
||||
function handleCloseTab(id: number) {
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((tab) => tab.id !== id)
|
||||
setCurrentTabId((current) => (current === id ? next[next.length - 1]?.id ?? null : current))
|
||||
return next
|
||||
})
|
||||
setTerminalStatuses((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
function handleCloseTab(tabId: string) {
|
||||
dispatchTab({ type: 'CLOSE_TAB', tabId })
|
||||
}
|
||||
|
||||
function closeTabContextMenu() {
|
||||
setTabContextMenu({ visible: false, x: 0, y: 0 })
|
||||
setTabContextMenu({ visible: false, x: 0, y: 0, targetTabId: null })
|
||||
}
|
||||
|
||||
function closeAllTabs() {
|
||||
setTabs([])
|
||||
setCurrentTabId(null)
|
||||
setTerminalStatuses({})
|
||||
dispatchTab({ type: 'CLOSE_ALL' })
|
||||
setSelectedFiles([])
|
||||
closeTabContextMenu()
|
||||
}
|
||||
|
||||
function duplicateTab(tabId: string) {
|
||||
const sourceTab = tabs.find((tab) => tab.tabId === tabId)
|
||||
if (!sourceTab) return
|
||||
dispatchTab({ type: 'DUPLICATE_TAB', tabId, newTabId: createTabId(sourceTab.connection.id) })
|
||||
setSelectedFiles([])
|
||||
closeTabContextMenu()
|
||||
}
|
||||
@@ -685,7 +797,7 @@ export default function WorkspacePage({
|
||||
>
|
||||
<SessionTree
|
||||
nodes={treeNodes}
|
||||
activeConnectionId={currentTabId}
|
||||
activeConnectionId={activeConnection?.id ?? null}
|
||||
connectionStatuses={connectionStatuses}
|
||||
openConnectionIds={openConnectionIds}
|
||||
selectedNodeId={selectedNodeId}
|
||||
@@ -703,25 +815,26 @@ export default function WorkspacePage({
|
||||
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
key={tab.tabId}
|
||||
className={cn(
|
||||
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
|
||||
currentTabId === tab.id ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||
currentTabKey === tab.tabId ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||
)}
|
||||
onClick={() => setCurrentTabId(tab.id)}
|
||||
onClick={() => dispatchTab({ type: 'ACTIVATE_TAB', tabId: tab.tabId })}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
closeTreeContextMenu()
|
||||
setTabContextMenu({ visible: true, x: event.clientX, y: event.clientY })
|
||||
const position = getClampedContextMenuPosition(event.clientX, event.clientY, 2)
|
||||
setTabContextMenu({ visible: true, x: position.x, y: position.y, targetTabId: tab.tabId })
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : 'text-slate-500'} />
|
||||
<Terminal size={14} className={currentTabKey === tab.tabId ? 'text-emerald-400' : 'text-slate-500'} />
|
||||
<span className="flex-1 truncate">{tab.name}</span>
|
||||
<span
|
||||
className="opacity-0 transition group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
handleCloseTab(tab.tabId)
|
||||
}}
|
||||
>
|
||||
×
|
||||
@@ -808,18 +921,16 @@ export default function WorkspacePage({
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const visible = tab.id === currentTabId && layout !== 'sftp'
|
||||
const visible = tab.tabId === currentTabKey && layout !== 'sftp'
|
||||
return (
|
||||
<div key={tab.id} className={cn('absolute inset-0', !visible && 'hidden')}>
|
||||
<div key={tab.tabId} className={cn('absolute inset-0', !visible && 'hidden')}>
|
||||
<TerminalPane
|
||||
connection={tab.connection}
|
||||
visible={visible}
|
||||
fontSize={terminalFontSize}
|
||||
fontFamily={terminalFontFamily}
|
||||
onStatusChange={(status) => {
|
||||
setTerminalStatuses((prev) =>
|
||||
prev[tab.id] === status ? prev : { ...prev, [tab.id]: status },
|
||||
)
|
||||
dispatchTab({ type: 'SET_TERMINAL_STATUS', tabId: tab.tabId, status })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -944,6 +1055,16 @@ export default function WorkspacePage({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => {
|
||||
if (tabContextMenu.targetTabId) {
|
||||
duplicateTab(tabContextMenu.targetTabId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制标签
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={closeAllTabs}
|
||||
|
||||
@@ -186,7 +186,7 @@ export interface MonitorMetrics {
|
||||
}
|
||||
|
||||
export interface WorkspaceTab {
|
||||
id: number
|
||||
tabId: string
|
||||
name: string
|
||||
connection: Connection
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user