chore: 保存 Agent 安全优化前基线
This commit is contained in:
1 parent
08696896f9
commit
ff016347cd
116 files changed
+3858
-2485
No files matched your search
@@ -7,9 +7,11 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
TextSelect,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
type AgentSafetyMode,
|
||||
} from '../../utils/agentCommandRisk'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import Button from '../common/Button'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
|
||||
import i18n from '../../i18n'
|
||||
@@ -41,6 +44,8 @@ interface AiTerminalPanelProps {
|
||||
connectionId?: string | null
|
||||
targetLabel?: string
|
||||
isLocal?: boolean
|
||||
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
|
||||
insertToTerminal?: (text: string) => boolean
|
||||
}
|
||||
|
||||
interface AgentStep {
|
||||
@@ -146,12 +151,12 @@ export default function AiTerminalPanel({
|
||||
connectionId = null,
|
||||
targetLabel = '当前 SSH 会话',
|
||||
isLocal = false,
|
||||
insertToTerminal,
|
||||
}: AiTerminalPanelProps) {
|
||||
const { t } = useTranslation('ai')
|
||||
const store = useAiProviderStore()
|
||||
const safetyMode = normalizeAgentSafetyMode(
|
||||
useSettingsStore((state) => state.settings.agentSafetyMode)
|
||||
)
|
||||
const safetyMode = normalizeAgentSafetyMode(useSettingsStore((state) => state.settings.agentSafetyMode))
|
||||
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
|
||||
const setSetting = useSettingsStore((state) => state.setSetting)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||
@@ -231,6 +236,7 @@ export default function AiTerminalPanel({
|
||||
setRunning(true)
|
||||
setLiveSteps([])
|
||||
const runSafetyMode = safetyMode
|
||||
const runConfirmAll = confirmAllCommands
|
||||
|
||||
const completedSteps: AgentStep[] = []
|
||||
const commandCounts = new Map<string, number>()
|
||||
@@ -293,7 +299,7 @@ export default function AiTerminalPanel({
|
||||
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
|
||||
}
|
||||
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
|
||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
|
||||
const fingerprint = agentCommandAllowFingerprint(command, risk)
|
||||
if (!sessionAllowRef.current.has(fingerprint)) {
|
||||
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||
@@ -308,11 +314,33 @@ export default function AiTerminalPanel({
|
||||
danger: risk === 'high',
|
||||
})
|
||||
if (runIdRef.current !== runId) return
|
||||
if (choice === 'insert') {
|
||||
const inserted = insertToTerminal?.(command) || false
|
||||
if (!inserted) {
|
||||
await writeClipboardText(command)
|
||||
}
|
||||
setLiveSteps([])
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId(),
|
||||
role: 'assistant',
|
||||
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }),
|
||||
steps: [...completedSteps],
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (choice === 'reject') {
|
||||
setLiveSteps([])
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
|
||||
{
|
||||
id: messageId(),
|
||||
role: 'assistant',
|
||||
content: t('step.rejected', { round }),
|
||||
steps: [...completedSteps],
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -323,7 +351,7 @@ export default function AiTerminalPanel({
|
||||
}
|
||||
|
||||
setLiveSteps([...completedSteps, proposed])
|
||||
const executed = await window.opsxApi.sshExec(connectionId, command)
|
||||
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
|
||||
if (runIdRef.current !== runId) return
|
||||
if (!executed.success && /已终止/.test(executed.error || '')) {
|
||||
return
|
||||
@@ -371,6 +399,50 @@ export default function AiTerminalPanel({
|
||||
window.setTimeout(() => setCopiedId(''), 1200)
|
||||
}
|
||||
|
||||
/** ④ 对当前终端可见输出做一次只读总结(不进入 Agent 循环、不执行命令)。 */
|
||||
const summarizeViewport = async () => {
|
||||
setError('')
|
||||
if (!provider || !model) {
|
||||
setError(t('error.noProvider'))
|
||||
return
|
||||
}
|
||||
if (!provider.hasApiKey && provider.authStyle !== 'none') {
|
||||
setError(t('error.noApiKey', { provider: provider.name }))
|
||||
return
|
||||
}
|
||||
const context = capture().selection.trim() || capture().viewport.trim()
|
||||
if (!context) {
|
||||
setError(t('error.noOutput'))
|
||||
return
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, { id: messageId(), role: 'user', content: t('panel.summarizeRequest') }])
|
||||
const runId = runIdRef.current + 1
|
||||
runIdRef.current = runId
|
||||
setRunning(true)
|
||||
try {
|
||||
const completion = await window.opsxApi.aiComplete({
|
||||
provider,
|
||||
model,
|
||||
action: 'explain',
|
||||
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
|
||||
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
|
||||
})
|
||||
if (runIdRef.current !== runId) return
|
||||
if (!completion.success || !completion.text) {
|
||||
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
|
||||
}
|
||||
setMessages((prev) => [...prev, { id: messageId(), role: 'assistant', content: completion.text!.trim() }])
|
||||
} catch (caught) {
|
||||
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
|
||||
setError(message)
|
||||
} finally {
|
||||
if (runIdRef.current === runId) {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopAgent = () => {
|
||||
runIdRef.current += 1
|
||||
dismissAgentRisk()
|
||||
@@ -398,7 +470,7 @@ export default function AiTerminalPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
|
||||
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850">
|
||||
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||
@@ -433,6 +505,19 @@ export default function AiTerminalPanel({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={confirmAllCommands}
|
||||
disabled={running}
|
||||
title={t('safety.confirmAllTitle')}
|
||||
aria-label={t('safety.confirmAllTitle')}
|
||||
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
|
||||
className={`ml-1 rounded p-1 disabled:opacity-50 ${
|
||||
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -444,7 +529,12 @@ export default function AiTerminalPanel({
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" title={t('panel.close')} onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
title={t('panel.close')}
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-txt-muted hover:bg-dark-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -452,9 +542,7 @@ export default function AiTerminalPanel({
|
||||
|
||||
<div ref={timelineRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
|
||||
{messages.length === 0 && liveSteps.length === 0 && !running && (
|
||||
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
|
||||
{t('panel.emptyHint')}
|
||||
</p>
|
||||
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">{t('panel.emptyHint')}</p>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
@@ -504,7 +592,10 @@ export default function AiTerminalPanel({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -534,13 +625,29 @@ export default function AiTerminalPanel({
|
||||
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
|
||||
</span>
|
||||
{running ? (
|
||||
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
|
||||
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
|
||||
{t('panel.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex items-center gap-1.5">
|
||||
<Send className="h-3.5 w-3.5" /> {t('panel.send')}
|
||||
</button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<TextSelect className="h-3.5 w-3.5" />}
|
||||
title={t('panel.summarizeTitle')}
|
||||
onClick={() => void summarizeViewport()}
|
||||
>
|
||||
{t('panel.summarize')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Send className="h-3.5 w-3.5" />}
|
||||
onClick={() => void runAgent()}
|
||||
>
|
||||
{t('panel.send')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
|
||||
@@ -553,11 +660,7 @@ export default function AiTerminalPanel({
|
||||
function StepCard({ step }: { step: AgentStep }) {
|
||||
const { t } = useTranslation('ai')
|
||||
const statusLabel =
|
||||
step.status === 'awaiting'
|
||||
? t('step.awaiting')
|
||||
: step.status === 'running'
|
||||
? t('step.running')
|
||||
: t('step.done')
|
||||
step.status === 'awaiting' ? t('step.awaiting') : step.status === 'running' ? t('step.running') : t('step.done')
|
||||
return (
|
||||
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
|
||||
|
||||
@@ -8,20 +8,15 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Search,
|
||||
Globe,
|
||||
Code2,
|
||||
LogIn,
|
||||
Plus,
|
||||
X,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Laptop,
|
||||
PanelLeft,
|
||||
} from 'lucide-react'
|
||||
import { Search, Globe, Code2, LogIn, Plus, X, ChevronRight, Settings, Laptop, PanelLeft } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores'
|
||||
import {
|
||||
useSessionStore,
|
||||
useSnippetStore,
|
||||
useCommandPaletteStore,
|
||||
useEntitlementStore,
|
||||
type Session,
|
||||
} from '../../stores'
|
||||
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
|
||||
@@ -62,12 +57,7 @@ const TYPE_BADGE_KEY: Record<CommandPaletteItemType, string> = {
|
||||
|
||||
// ─── 组件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommandPalette({
|
||||
onNewSession,
|
||||
onQuickConnect,
|
||||
onOpenSettings,
|
||||
onEditSession,
|
||||
}: CommandPaletteProps) {
|
||||
export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, onEditSession }: CommandPaletteProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -210,9 +200,7 @@ export function CommandPalette({
|
||||
id: `snippet:${snippet.id}`,
|
||||
type: 'snippet',
|
||||
label: snippet.name,
|
||||
description: snippet.content.length > 60
|
||||
? snippet.content.slice(0, 60) + '…'
|
||||
: snippet.content,
|
||||
description: snippet.content.length > 60 ? snippet.content.slice(0, 60) + '…' : snippet.content,
|
||||
icon: <Code2 size={16} />,
|
||||
action: () => {
|
||||
// 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展)
|
||||
@@ -270,15 +258,11 @@ export function CommandPalette({
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev < filteredItems.length - 1 ? prev + 1 : 0
|
||||
)
|
||||
setSelectedIndex((prev) => (prev < filteredItems.length - 1 ? prev + 1 : 0))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredItems.length - 1
|
||||
)
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredItems.length - 1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
@@ -331,40 +315,50 @@ export function CommandPalette({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" />
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* Palette container */}
|
||||
<div
|
||||
className="relative w-[600px] max-h-[460px] bg-dark-800 border border-dark-700 rounded-lg shadow-2xl overflow-hidden flex flex-col animate-scale-in"
|
||||
className="relative flex max-h-[460px] w-[600px] animate-scale-in flex-col overflow-hidden rounded-lg border border-dark-700 bg-dark-800 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('palette.aria')}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
|
||||
<Search size={18} className="text-txt-muted flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
|
||||
<Search size={18} className="flex-shrink-0 text-txt-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="palette-listbox"
|
||||
aria-activedescendant={filteredItems.length > 0 ? `palette-item-${selectedIndex}` : undefined}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('palette.placeholder')}
|
||||
className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm"
|
||||
className="flex-1 bg-transparent text-sm text-content-primary placeholder-ph outline-none"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] text-txt-muted bg-dark-700 rounded border border-dark-600">
|
||||
<kbd className="hidden items-center gap-0.5 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[10px] text-txt-muted sm:inline-flex">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto py-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
id="palette-listbox"
|
||||
role="listbox"
|
||||
aria-label={t('palette.aria')}
|
||||
className="flex-1 overflow-y-auto py-1"
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
|
||||
<Search size={32} className="mb-2 opacity-30" />
|
||||
<p className="text-sm">{t('palette.empty')}</p>
|
||||
<p className="text-xs text-txt-faint mt-1">{t('palette.emptyHint')}</p>
|
||||
<p className="mt-1 text-xs text-txt-faint">{t('palette.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
groupedItems.map((group) => {
|
||||
@@ -374,13 +368,13 @@ export function CommandPalette({
|
||||
return (
|
||||
<div key={group.type}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium text-txt-muted uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium uppercase tracking-wider text-txt-muted">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
|
||||
>
|
||||
{t(TYPE_BADGE_KEY[group.type])}
|
||||
</span>
|
||||
<span className="flex-1 h-px bg-dark-700" />
|
||||
<span className="h-px flex-1 bg-dark-700" />
|
||||
<span className="text-txt-faint">{group.items.length}</span>
|
||||
</div>
|
||||
|
||||
@@ -391,14 +385,15 @@ export function CommandPalette({
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`palette-item-${globalIndex}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
data-index={globalIndex}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
|
||||
${isSelected
|
||||
className={`flex cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-75 ${
|
||||
isSelected
|
||||
? 'bg-accent-blue/15 text-content-primary'
|
||||
: 'text-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
onClick={() => {
|
||||
item.action()
|
||||
close()
|
||||
@@ -407,7 +402,7 @@ export function CommandPalette({
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={`flex-shrink-0 w-5 h-5 flex items-center justify-center ${
|
||||
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center ${
|
||||
isSelected ? 'text-accent-blue' : 'text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
@@ -415,28 +410,19 @@ export function CommandPalette({
|
||||
</span>
|
||||
|
||||
{/* Label + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{item.label}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-[11px] text-txt-muted truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-txt-muted">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcut */}
|
||||
{item.shortcut && (
|
||||
<kbd className="flex-shrink-0 text-[11px] text-txt-muted bg-dark-700 px-1.5 py-0.5 rounded border border-dark-600">
|
||||
<kbd className="flex-shrink-0 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[11px] text-txt-muted">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
|
||||
{/* Type badge */}
|
||||
<span
|
||||
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[item.type]}`}
|
||||
>
|
||||
{t(TYPE_BADGE_KEY[item.type])}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -447,24 +433,22 @@ export function CommandPalette({
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-dark-700 text-[11px] text-txt-faint">
|
||||
<div className="flex items-center justify-between border-t border-dark-700 px-4 py-2 text-[11px] text-txt-faint">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↑↓</kbd>
|
||||
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↑↓</kbd>
|
||||
{t('palette.hint.nav')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↵</kbd>
|
||||
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↵</kbd>
|
||||
{t('palette.hint.run')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">esc</kbd>
|
||||
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">esc</kbd>
|
||||
{t('palette.hint.close')}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{t('palette.resultCount', { count: filteredItems.length })}
|
||||
</span>
|
||||
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,8 @@ export interface ConfirmOptions {
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
/** Agent 高风险确认:Reject / Once / Always(Always 仅会话内存) */
|
||||
export type AgentRiskChoice = 'reject' | 'once' | 'always'
|
||||
/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */
|
||||
export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert'
|
||||
|
||||
export interface AgentRiskConfirmOptions {
|
||||
title: string
|
||||
@@ -155,11 +155,7 @@ export function useConfirmDialog() {
|
||||
cancelText={state.cancelText}
|
||||
isDanger={state.danger}
|
||||
size="sm"
|
||||
icon={
|
||||
state.danger ? (
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
) : undefined
|
||||
}
|
||||
icon={state.danger ? <AlertTriangle className="h-5 w-5 text-red-400" /> : undefined}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
|
||||
</Modal>
|
||||
@@ -171,12 +167,15 @@ export function useConfirmDialog() {
|
||||
showConfirm={false}
|
||||
size="sm"
|
||||
disableBackdropClose
|
||||
icon={<AlertTriangle className="w-5 h-5 text-red-400" />}
|
||||
icon={<AlertTriangle className="h-5 w-5 text-red-400" />}
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
|
||||
{t('ai:confirm.reject')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
|
||||
{t('ai:confirm.insert')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={agentRisk.danger ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ConnectionState } from '../../../shared/types/connection.types'
|
||||
|
||||
const STATUS_DOT: Record<ConnectionState, string> = {
|
||||
connected: 'bg-feedback-success',
|
||||
connecting: 'bg-feedback-warning animate-pulse',
|
||||
reconnecting: 'bg-feedback-warning animate-pulse',
|
||||
error: 'bg-feedback-error',
|
||||
suspended: 'bg-surface-active',
|
||||
disconnected: 'bg-surface-active',
|
||||
background: 'bg-feedback-success opacity-60',
|
||||
}
|
||||
|
||||
export default function StatusDot({
|
||||
state,
|
||||
className = 'h-1.5 w-1.5',
|
||||
}: {
|
||||
state: ConnectionState
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block flex-shrink-0 rounded-full ${STATUS_DOT[state] ?? 'bg-surface-active'} ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,20 +6,13 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
X,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import { Wifi, WifiOff, RefreshCw, X, Clock, HardDrive, Activity } from 'lucide-react'
|
||||
import { useConnectionStore } from '../../stores/connection'
|
||||
import { usePoolStore } from '../../stores/connection/poolStore'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import type { PooledConnection, ConnectionState } from '../../stores/connection/types'
|
||||
import type { PooledConnection } from '../../stores/connection/types'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import StatusDot from '../common/StatusDot'
|
||||
|
||||
// ============== Helpers ==============
|
||||
|
||||
@@ -43,30 +36,6 @@ function formatDuration(ms: number): string {
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
|
||||
// ============== Status Dot ==============
|
||||
|
||||
interface StatusDotProps {
|
||||
state: ConnectionState
|
||||
}
|
||||
|
||||
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
|
||||
connected: 'bg-green-400',
|
||||
connecting: 'bg-yellow-400 animate-pulse',
|
||||
background: 'bg-green-400 opacity-60',
|
||||
suspended: 'bg-surface-active',
|
||||
error: 'bg-red-400',
|
||||
disconnected: 'bg-surface-active',
|
||||
reconnecting: 'bg-yellow-400 animate-pulse',
|
||||
}
|
||||
|
||||
function StatusDot({ state }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-surface-active'}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============== Connection Card ==============
|
||||
|
||||
interface ConnectionCardProps {
|
||||
@@ -79,26 +48,24 @@ interface ConnectionCardProps {
|
||||
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const isReconnecting = reconnectingId === conn.connectionId
|
||||
const canReconnect =
|
||||
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
|
||||
const canDisconnect =
|
||||
conn.state !== 'disconnected' && conn.state !== 'error'
|
||||
const canReconnect = conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
|
||||
const canDisconnect = conn.state !== 'disconnected' && conn.state !== 'error'
|
||||
|
||||
const durationMs = Date.now() - conn.stats.connectedAt
|
||||
const sessionCount = conn.shellIds.size + conn.sftpIds.size
|
||||
|
||||
return (
|
||||
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
|
||||
<div className="space-y-2.5 rounded-lg bg-dark-900 p-3">
|
||||
{/* Header: host + status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot state={conn.state} />
|
||||
<span className="text-sm text-content-primary truncate font-medium">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot state={conn.state} className="h-2 w-2" />
|
||||
<span className="truncate text-sm font-medium text-content-primary">
|
||||
{conn.config.username}@{conn.config.host}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
conn.state === 'connected'
|
||||
? 'bg-feedback-success/10 text-feedback-success'
|
||||
: conn.state === 'connecting' || conn.state === 'reconnecting'
|
||||
@@ -120,20 +87,19 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-txt-muted">
|
||||
{/* Port */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
:{conn.config.port ?? 22}
|
||||
<Activity className="h-3 w-3" />:{conn.config.port ?? 22}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(durationMs)}
|
||||
</span>
|
||||
|
||||
{/* Transfer */}
|
||||
{conn.stats.bytesTransferred > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{formatBytes(conn.stats.bytesTransferred)}
|
||||
</span>
|
||||
)}
|
||||
@@ -156,11 +122,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
type="button"
|
||||
onClick={() => onReconnect(conn.connectionId)}
|
||||
disabled={isReconnecting}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
||||
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="flex items-center gap-1 rounded bg-accent-blue/10 px-2 py-1 text-xs text-accent-blue transition-colors hover:bg-accent-blue/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`h-3 w-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
||||
{isReconnecting ? t('connectionPanel.reconnecting') : t('connectionPanel.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
@@ -168,10 +132,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDisconnect(conn.connectionId)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs
|
||||
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
|
||||
className="flex items-center gap-1 rounded bg-red-400/10 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-400/20"
|
||||
>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
<WifiOff className="h-3 w-3" />
|
||||
{t('connectionPanel.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
@@ -179,7 +142,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
|
||||
|
||||
{/* Error message */}
|
||||
{conn.error && (
|
||||
<p className="text-xs text-red-400 truncate" title={conn.error}>
|
||||
<p className="truncate text-xs text-red-400" title={conn.error}>
|
||||
{conn.error}
|
||||
</p>
|
||||
)}
|
||||
@@ -208,7 +171,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
|
||||
<div key={item.label} className="rounded-lg bg-dark-900 p-2 text-center">
|
||||
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
|
||||
<div className="text-[10px] text-txt-muted">{item.label}</div>
|
||||
</div>
|
||||
@@ -225,11 +188,7 @@ export default function ConnectionPanel() {
|
||||
const close = useConnectionPanelStore((s) => s.close)
|
||||
|
||||
const connections = usePoolStore((s) => s.connections)
|
||||
const {
|
||||
resumeConnection,
|
||||
disconnectConnection,
|
||||
getStats,
|
||||
} = useConnectionStore()
|
||||
const { resumeConnection, disconnectConnection, getStats } = useConnectionStore()
|
||||
|
||||
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
||||
|
||||
@@ -250,14 +209,14 @@ export default function ConnectionPanel() {
|
||||
setReconnectingId(null)
|
||||
}
|
||||
},
|
||||
[resumeConnection],
|
||||
[resumeConnection]
|
||||
)
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
async (connectionId: string) => {
|
||||
await disconnectConnection(connectionId)
|
||||
},
|
||||
[disconnectConnection],
|
||||
[disconnectConnection]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -265,34 +224,27 @@ export default function ConnectionPanel() {
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
|
||||
onClick={close}
|
||||
/>
|
||||
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
|
||||
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
|
||||
flex flex-col shadow-xl animate-slide-in-right"
|
||||
>
|
||||
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right fixed bottom-0 right-0 top-0 z-50 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-content-primary flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-accent-blue" />
|
||||
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
|
||||
<Wifi className="h-4 w-4 text-accent-blue" />
|
||||
{t('connectionPanel.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="p-1 rounded hover:bg-dark-700 text-txt-muted hover:text-content-primary transition-colors"
|
||||
className="rounded p-1 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
|
||||
<div className="border-dark-700/30 shrink-0 border-b px-4 py-3">
|
||||
<StatsOverview
|
||||
total={stats.total}
|
||||
connected={stats.connected}
|
||||
@@ -302,7 +254,7 @@ export default function ConnectionPanel() {
|
||||
</div>
|
||||
|
||||
{/* Connection List */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
|
||||
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
||||
{connList.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={WifiOff}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Cast,
|
||||
ClipboardPaste,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||||
import StatusDot from '../common/StatusDot'
|
||||
import type {
|
||||
DesktopFrameEvent,
|
||||
DesktopPointerButton,
|
||||
@@ -96,9 +88,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||
const fullscreenActive = useDesktopFullscreenStore(
|
||||
(state) => state.active && state.tabId === tabId
|
||||
)
|
||||
const fullscreenActive = useDesktopFullscreenStore((state) => state.active && state.tabId === tabId)
|
||||
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||||
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||||
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
||||
@@ -126,18 +116,14 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const local = sessions.find((item) => item.id === tab.sessionId)
|
||||
if (local) {
|
||||
if (!cancelled) {
|
||||
setResolvedConfig(
|
||||
protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)
|
||||
)
|
||||
setResolvedConfig(protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local))
|
||||
}
|
||||
return
|
||||
}
|
||||
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
|
||||
if (!cancelled && response?.success && response.session) {
|
||||
setResolvedConfig(
|
||||
protocol === 'rdp'
|
||||
? sessionToRdpConfig(response.session)
|
||||
: sessionToVncConfig(response.session)
|
||||
protocol === 'rdp' ? sessionToRdpConfig(response.session) : sessionToVncConfig(response.session)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -548,11 +534,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
pressed &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.code === 'KeyV' &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||||
|
||||
if (isPasteChord) {
|
||||
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
|
||||
@@ -597,10 +579,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
|
||||
const Icon = protocol === 'rdp' ? Monitor : Cast
|
||||
const hostLabel =
|
||||
resolvedConfig && 'host' in resolvedConfig
|
||||
? resolvedConfig.host
|
||||
: tab?.title
|
||||
const hostLabel = resolvedConfig && 'host' in resolvedConfig ? resolvedConfig.host : tab?.title
|
||||
const connected = tab?.connectionStatus === 'connected'
|
||||
|
||||
const toolBtn =
|
||||
@@ -610,8 +589,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||
fullscreenActive
|
||||
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
|
||||
: 'border-b border-dark-700/50 bg-dark-800/80'
|
||||
? 'border-dark-700/60 bg-dark-900/95 rounded-b-lg border border-t-0 shadow-lg shadow-black/40 backdrop-blur'
|
||||
: 'border-dark-700/50 bg-dark-800/80 border-b'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (fullscreenActive) {
|
||||
@@ -636,11 +615,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
{tab?.connectionStatus === 'connecting' ? (
|
||||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
|
||||
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
|
||||
}`}
|
||||
/>
|
||||
<StatusDot state={connected ? 'connected' : tab?.connectionStatus === 'error' ? 'error' : 'disconnected'} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
||||
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||||
@@ -739,7 +714,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="cursor-default outline-none shadow-lg shadow-black/40"
|
||||
className="cursor-default shadow-lg shadow-black/40 outline-none"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => mapPointer(event)}
|
||||
onMouseDown={(event) => mapPointer(event, true)}
|
||||
|
||||
+280
-188
@@ -3,7 +3,8 @@
|
||||
* 支持宽度拖拽调整
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||
@@ -18,8 +19,11 @@ import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol'
|
||||
import {
|
||||
DEFAULT_SESSION_GROUP,
|
||||
groupDisplayName,
|
||||
groupSessionsInDisplayOrder,
|
||||
normalizeGroupId,
|
||||
} from '../../utils/sessionGroups'
|
||||
import { localeCompareText } from '../../i18n/format'
|
||||
import type { SessionReorderEntry } from '../../../shared/types/session.types'
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 180
|
||||
const MAX_SIDEBAR_WIDTH = 400
|
||||
@@ -33,12 +37,12 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
|
||||
const { t } = useTranslation(['sessions', 'common'])
|
||||
const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore()
|
||||
const { sessions, setSessions, adoptSession, removeSession, updateSession } = useSessionStore()
|
||||
const { settings, setSetting } = useSettingsStore()
|
||||
const { addTab } = useTabStore()
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
@@ -54,7 +58,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
|
||||
const requirePro = useEntitlementStore((s) => s.requirePro)
|
||||
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -67,7 +71,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
window.opsxApi?.sessionGetAll()
|
||||
window.opsxApi
|
||||
?.sessionGetAll()
|
||||
.then((result) => {
|
||||
if (result.success && result.sessions) setSessions(result.sessions)
|
||||
})
|
||||
@@ -94,36 +99,58 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
// Hug content (`w-max`); keep the compact menu inside the viewport.
|
||||
useLayoutEffect(() => {
|
||||
const el = contextMenuRef.current
|
||||
if (!el || !contextMenu) return
|
||||
const margin = 8
|
||||
const width = el.offsetWidth
|
||||
const height = el.offsetHeight
|
||||
let left = contextMenu.x
|
||||
let top = contextMenu.y
|
||||
if (left + width + margin > window.innerWidth) {
|
||||
left = Math.max(margin, window.innerWidth - width - margin)
|
||||
}
|
||||
if (top + height + margin > window.innerHeight) {
|
||||
top = Math.max(margin, window.innerHeight - height - margin)
|
||||
}
|
||||
el.style.left = `${left}px`
|
||||
el.style.top = `${top}px`
|
||||
}, [contextMenu])
|
||||
|
||||
// Resize handler
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX
|
||||
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX
|
||||
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
// Save to settings on drag end
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
// Save to settings on drag end
|
||||
setSetting('sidebarWidth', sidebarWidth)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [sidebarWidth, setSetting])
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[sidebarWidth, setSetting]
|
||||
)
|
||||
|
||||
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
|
||||
useEffect(() => {
|
||||
@@ -171,16 +198,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
}, [])
|
||||
|
||||
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
|
||||
const batchableSessions = useMemo(
|
||||
() => sessions.filter((session) => isSshSession(session)),
|
||||
[sessions]
|
||||
)
|
||||
const batchableSessions = useMemo(() => sessions.filter((session) => isSshSession(session)), [sessions])
|
||||
|
||||
const visibleSessions = batchableSessions.filter((session) => {
|
||||
const query = searchTerm.trim().toLowerCase()
|
||||
return !query || session.name.toLowerCase().includes(query) ||
|
||||
return (
|
||||
!query ||
|
||||
session.name.toLowerCase().includes(query) ||
|
||||
session.host.toLowerCase().includes(query) ||
|
||||
session.username?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const toggleSelectVisible = useCallback(() => {
|
||||
@@ -244,9 +271,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
|
||||
const persistSessionGroups = useCallback(
|
||||
async (next: string[]) => {
|
||||
const cleaned = next
|
||||
.map((item) => normalizeGroupId(item))
|
||||
.filter((item) => item !== DEFAULT_SESSION_GROUP)
|
||||
const cleaned = next.map((item) => normalizeGroupId(item)).filter((item) => item !== DEFAULT_SESSION_GROUP)
|
||||
const unique = Array.from(new Set(cleaned))
|
||||
await setSetting('sessionGroups', unique)
|
||||
},
|
||||
@@ -317,6 +342,71 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
[confirm, persistSessionGroups, sessions, settings.sessionGroups, t]
|
||||
)
|
||||
|
||||
/**
|
||||
* Persist a batch of sidebar order entries optimistically. On a failed write,
|
||||
* reload from storage so the tree reflects the real persisted order.
|
||||
*/
|
||||
const applyReorder = useCallback(
|
||||
async (entries: SessionReorderEntry[]) => {
|
||||
const all = useSessionStore.getState().sessions
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]))
|
||||
setSessions(
|
||||
all.map((session) => {
|
||||
const entry = byId.get(session.id)
|
||||
if (!entry) return session
|
||||
return {
|
||||
...session,
|
||||
sortOrder: entry.sortOrder,
|
||||
...(entry.group !== undefined ? { group: entry.group === null ? undefined : entry.group } : {}),
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = await window.opsxApi?.sessionReorder(entries)
|
||||
if (result && !result.success) {
|
||||
const fresh = await window.opsxApi?.sessionGetAll()
|
||||
if (fresh?.success && fresh.sessions) setSessions(fresh.sessions)
|
||||
}
|
||||
},
|
||||
[setSessions]
|
||||
)
|
||||
|
||||
/** Drag commit: place `dragId` at `targetIndex` (display order) of `targetGroup`. */
|
||||
const handleReorderSessions = useCallback(
|
||||
(dragId: string, targetGroup: string, targetIndex: number) => {
|
||||
const all = useSessionStore.getState().sessions
|
||||
const dragged = all.find((session) => session.id === dragId)
|
||||
if (!dragged) return
|
||||
const members = groupSessionsInDisplayOrder(all, targetGroup).filter((session) => session.id !== dragId)
|
||||
const index = Math.max(0, Math.min(targetIndex, members.length))
|
||||
members.splice(index, 0, dragged)
|
||||
const nextGroup = targetGroup === DEFAULT_SESSION_GROUP ? null : targetGroup
|
||||
const changedGroup = (dragged.group || DEFAULT_SESSION_GROUP) !== targetGroup
|
||||
// Skip the write when the drop didn't change the visible order.
|
||||
const visibleIds = (list: Session[]) => list.map((session) => session.id).join('\u0000')
|
||||
if (!changedGroup && visibleIds(members) === visibleIds(groupSessionsInDisplayOrder(all, targetGroup))) {
|
||||
return
|
||||
}
|
||||
const entries: SessionReorderEntry[] = members.map((session, order) => ({
|
||||
id: session.id,
|
||||
sortOrder: order,
|
||||
...(session.id === dragId && changedGroup ? { group: nextGroup } : {}),
|
||||
}))
|
||||
void applyReorder(entries)
|
||||
},
|
||||
[applyReorder]
|
||||
)
|
||||
|
||||
/** One-shot alphabetical rewrite of one group's manual order. */
|
||||
const sortGroupByName = useCallback(
|
||||
(group: string) => {
|
||||
const all = useSessionStore.getState().sessions
|
||||
const members = groupSessionsInDisplayOrder(all, group).sort((a, b) => localeCompareText(a.name, b.name))
|
||||
if (members.length < 2) return
|
||||
void applyReorder(members.map((session, order) => ({ id: session.id, sortOrder: order })))
|
||||
},
|
||||
[applyReorder]
|
||||
)
|
||||
|
||||
const handleContextMenuAction = async (action: string) => {
|
||||
if (!contextMenu) return
|
||||
|
||||
@@ -331,6 +421,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
const group = contextMenu.group
|
||||
if (action === 'new-session') onNewSession(group)
|
||||
if (action === 'new-group') await createGroup()
|
||||
if (action === 'sort-by-name') sortGroupByName(group)
|
||||
if (action === 'rename-group') await renameGroup(group)
|
||||
if (action === 'delete-group') await deleteEmptyGroup(group)
|
||||
setContextMenu(null)
|
||||
@@ -350,14 +441,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
break
|
||||
case 'duplicate': {
|
||||
const { id: _id, ...sessionWithoutId } = session
|
||||
window.opsxApi?.sessionCreate({
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (${t('common:action.clone')})`,
|
||||
}).then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
addSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
window.opsxApi
|
||||
?.sessionCreate({
|
||||
...sessionWithoutId,
|
||||
name: `${session.name} (${t('common:action.clone')})`,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result?.success && result.session) {
|
||||
adoptSession(result.session as Session)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
@@ -379,79 +472,76 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-3.5 border-b border-dark-700/30">
|
||||
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
|
||||
<img
|
||||
src={appIconUrl}
|
||||
alt="OpsXShell"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
|
||||
OpsXShell
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className="bg-dark-800/95 border-dark-700/40 relative flex h-full flex-col border-r"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-dark-700/30 border-b p-3.5">
|
||||
<div className="mb-3.5 flex items-center gap-2.5 px-0.5">
|
||||
<img
|
||||
src={appIconUrl}
|
||||
alt="OpsXShell"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold leading-none tracking-tight text-content-primary">OpsXShell</div>
|
||||
<div className="mt-1 text-[10px] text-txt-faint">{t('common:sidebar.brandSub')}</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
|
||||
{onCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title={t('common:sidebar.collapse')}
|
||||
aria-label={t('common:sidebar.collapseAria')}
|
||||
className="hover:bg-dark-700/70 group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:text-txt-muted"
|
||||
>
|
||||
<ChevronsLeft
|
||||
className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onCollapse && (
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title={t('common:sidebar.collapse')}
|
||||
aria-label={t('common:sidebar.collapseAria')}
|
||||
className="group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:bg-dark-700/70 hover:text-txt-muted"
|
||||
onClick={onQuickConnect}
|
||||
title={t('sidebar.quickConnect')}
|
||||
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
|
||||
>
|
||||
<ChevronsLeft className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
|
||||
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
|
||||
{t('sidebar.quickConnectShort')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession()}
|
||||
title={t('sidebar.newSession')}
|
||||
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||
{t('sidebar.newSessionShort')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQuickConnect}
|
||||
title={t('sidebar.quickConnect')}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
|
||||
{t('sidebar.quickConnectShort')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession()}
|
||||
title={t('sidebar.newSession')}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
|
||||
{t('sidebar.newSessionShort')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="p-3 border-b border-dark-700/20 space-y-2">
|
||||
<>
|
||||
<div className="border-dark-700/20 space-y-2 border-b p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t('sidebar.searchPlaceholder')}
|
||||
/>
|
||||
<SearchBox value={searchTerm} onChange={setSearchTerm} placeholder={t('sidebar.searchPlaceholder')} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
|
||||
aria-label={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
|
||||
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
|
||||
onClick={() => (batchMode ? finishBatchMode() : setBatchMode(true))}
|
||||
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
batchMode
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
@@ -463,11 +553,15 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
</div>
|
||||
{batchMode && (
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="text-txt-muted truncate">
|
||||
<span className="truncate text-txt-muted">
|
||||
{t('sidebar.batchSelected', { count: batchSelection.size })}
|
||||
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
|
||||
</span>
|
||||
<button type="button" onClick={toggleSelectVisible} className="flex-shrink-0 text-accent-blue hover:underline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSelectVisible}
|
||||
className="flex-shrink-0 text-accent-blue hover:underline"
|
||||
>
|
||||
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
|
||||
? t('sidebar.deselectAllVisible')
|
||||
: t('sidebar.selectAllVisible')}
|
||||
@@ -486,6 +580,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
onContextMenu={handleContextMenu}
|
||||
onGroupContextMenu={handleGroupContextMenu}
|
||||
onBlankContextMenu={handleBlankContextMenu}
|
||||
onReorderSessions={handleReorderSessions}
|
||||
searchTerm={searchTerm}
|
||||
loading={sessionsLoading}
|
||||
onNewSession={() => onNewSession()}
|
||||
@@ -494,7 +589,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
onToggleSelection={toggleBatchSelection}
|
||||
/>
|
||||
{batchMode && (
|
||||
<div className="grid grid-cols-1 gap-2 border-t border-dark-700/30 p-3">
|
||||
<div className="border-dark-700/30 grid grid-cols-1 gap-2 border-t p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
@@ -506,91 +601,88 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-dark-700/20">
|
||||
<button
|
||||
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>{t('sidebar.settings')}</span>
|
||||
</button>
|
||||
{/* Footer */}
|
||||
<div className="border-dark-700/20 border-t p-3">
|
||||
<button
|
||||
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm text-txt-muted transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('sidebar.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context Menu — portaled so sidebar overflow cannot stretch/clip it */}
|
||||
{contextMenu &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-[200] w-max rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(contextMenu.kind === 'blank'
|
||||
? [
|
||||
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
|
||||
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
|
||||
]
|
||||
: contextMenu.kind === 'group'
|
||||
? [
|
||||
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
|
||||
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
|
||||
{ label: t('contextMenu.group.sortByName'), action: 'sort-by-name' },
|
||||
...(contextMenu.group !== DEFAULT_SESSION_GROUP
|
||||
? [
|
||||
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
|
||||
{
|
||||
label: t('contextMenu.group.deleteEmptyGroup'),
|
||||
action: 'delete-group',
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
{ label: t('contextMenu.session.open'), action: 'connect' },
|
||||
...(isSshSession(contextMenu.session)
|
||||
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
|
||||
: []),
|
||||
{ label: t('contextMenu.session.edit'), action: 'edit' },
|
||||
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
|
||||
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
|
||||
]
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.action}
|
||||
type="button"
|
||||
onClick={() => handleContextMenuAction(item.action)}
|
||||
className={`block w-full whitespace-nowrap px-2.5 py-1.5 text-left text-xs transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(contextMenu.kind === 'blank'
|
||||
? [
|
||||
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
|
||||
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
|
||||
]
|
||||
: contextMenu.kind === 'group'
|
||||
? [
|
||||
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
|
||||
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
|
||||
...(contextMenu.group !== DEFAULT_SESSION_GROUP
|
||||
? [
|
||||
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
|
||||
{ label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
{ label: t('contextMenu.session.open'), action: 'connect' },
|
||||
...(isSshSession(contextMenu.session)
|
||||
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
|
||||
: []),
|
||||
{ label: t('contextMenu.session.edit'), action: 'edit' },
|
||||
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
|
||||
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
|
||||
]
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.action}
|
||||
onClick={() => handleContextMenuAction(item.action)}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${
|
||||
item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Resize Handle — 双击也可折叠 */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
onCollapse?.()
|
||||
}}
|
||||
title={t('sidebar.resizeHint')}
|
||||
className={`absolute bottom-0 right-0 top-0 w-1 cursor-col-resize transition-all duration-150 hover:w-1.5 hover:bg-accent-blue/50 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)] active:bg-accent-blue/70 ${isResizing ? 'w-1.5 bg-accent-blue/70 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''} `}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle — 双击也可折叠 */}
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault()
|
||||
onCollapse?.()
|
||||
}}
|
||||
title={t('sidebar.resizeHint')}
|
||||
className={`
|
||||
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
|
||||
hover:bg-accent-blue/50 active:bg-accent-blue/70
|
||||
hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)]
|
||||
transition-all duration-150
|
||||
${isResizing ? 'bg-accent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
{PromptDialogComponent}
|
||||
{ConfirmDialogComponent}
|
||||
{PromptDialogComponent}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,13 +22,7 @@ interface TabBarProps {
|
||||
|
||||
function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activateTab,
|
||||
addTab,
|
||||
moveTab,
|
||||
} = useTabStore()
|
||||
const { tabs, activeTabId, activateTab, addTab, moveTab, updateTabTitle } = useTabStore()
|
||||
|
||||
// Drag state
|
||||
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
||||
@@ -115,16 +109,28 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"
|
||||
>
|
||||
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
|
||||
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
|
||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
aria-label={t('tab.listAria')}
|
||||
onKeyDown={(event) => {
|
||||
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return
|
||||
const index = tabs.findIndex((tab) => tab.id === activeTabId)
|
||||
if (index < 0 || tabs.length === 0) return
|
||||
event.preventDefault()
|
||||
let next = index
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
|
||||
if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
|
||||
if (event.key === 'Home') next = 0
|
||||
if (event.key === 'End') next = tabs.length - 1
|
||||
activateTab(tabs[next].id)
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector<HTMLElement>('[data-tab-item][aria-selected="true"]')?.focus()
|
||||
})
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
@@ -144,6 +150,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onRename={
|
||||
tab.type !== 'welcome' && tab.type !== 'settings' ? (title) => updateTabTitle(tab.id, title) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -151,7 +160,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenLauncher}
|
||||
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
|
||||
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-surface-overlay text-content-secondary transition-colors hover:bg-accent-blue hover:text-white"
|
||||
title={t('launcher.title')}
|
||||
aria-label={t('launcher.title')}
|
||||
>
|
||||
|
||||
@@ -17,15 +17,10 @@ interface TabContentProps {
|
||||
onQuickConnect?: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
onImportSessions?: () => void
|
||||
}
|
||||
|
||||
function ProGate({
|
||||
feature,
|
||||
children,
|
||||
}: {
|
||||
feature: ProFeature
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
function ProGate({ feature, children }: { feature: ProFeature; children: React.ReactNode }) {
|
||||
const canUse = useEntitlementStore((s) => s.canUse(feature))
|
||||
if (!canUse) {
|
||||
return <UpgradeView feature={feature} />
|
||||
@@ -39,11 +34,12 @@ function TabContent({
|
||||
onQuickConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
onImportSessions,
|
||||
}: TabContentProps) {
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div className="relative h-full w-full">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId
|
||||
|
||||
@@ -89,10 +85,7 @@ function TabContent({
|
||||
break
|
||||
case 'upgrade':
|
||||
content = (
|
||||
<UpgradeView
|
||||
feature={tab.upgradeFeature as ProFeature | undefined}
|
||||
comingSoon={tab.upgradeComingSoon}
|
||||
/>
|
||||
<UpgradeView feature={tab.upgradeFeature as ProFeature | undefined} comingSoon={tab.upgradeComingSoon} />
|
||||
)
|
||||
break
|
||||
case 'batch':
|
||||
@@ -110,6 +103,7 @@ function TabContent({
|
||||
onQuickConnect={onQuickConnect}
|
||||
onRdpConnect={onRdpConnect}
|
||||
onVncConnect={onVncConnect}
|
||||
onImportSessions={onImportSessions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -117,12 +111,12 @@ function TabContent({
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`tabpanel-${tab.id}`}
|
||||
role="tabpanel"
|
||||
aria-hidden={!isActive}
|
||||
{...(!isActive ? { inert: '' } : {})}
|
||||
className={`absolute inset-0 transition-opacity duration-150 ${
|
||||
isActive
|
||||
? 'visible opacity-100 z-10'
|
||||
: 'invisible opacity-0 z-0 pointer-events-none'
|
||||
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* SessionTree — 分组会话列表
|
||||
* 单击选中 · 双击打开终端 · 右键更多操作
|
||||
* 单击选中 · 双击打开终端 · 右键更多操作 · 拖拽调整组内顺序或移动分组
|
||||
*
|
||||
* macOS WKWebView 的 HTML5 drop 不可靠(SFTP 已改指针拖),侧栏同样用 pointer 拖拽。
|
||||
*/
|
||||
|
||||
import { Check, ChevronRight, Folder, ServerOff } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SessionTreeSkeleton } from '../../common/Skeleton'
|
||||
import HostDistroIcon from '../../common/HostDistroIcon'
|
||||
import type { Session } from '../../../stores/sessionStore'
|
||||
import EmptyState from '../../common/EmptyState'
|
||||
import { collectSessionGroups, groupDisplayName } from '../../../utils/sessionGroups'
|
||||
import { collectSessionGroups, compareBySortOrder, groupDisplayName } from '../../../utils/sessionGroups'
|
||||
import { localeCompareText } from '../../../i18n/format'
|
||||
|
||||
const POINTER_DRAG_THRESHOLD = 6
|
||||
const AUTO_EXPAND_MS = 400
|
||||
const DRAG_BODY_CLASS = 'session-tree-dragging'
|
||||
|
||||
interface SessionTreeProps {
|
||||
sessions: Session[]
|
||||
/** Persisted empty groups + any extras to always show. */
|
||||
@@ -24,6 +32,8 @@ interface SessionTreeProps {
|
||||
onContextMenu: (e: React.MouseEvent, session: Session) => void
|
||||
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
|
||||
onBlankContextMenu?: (e: React.MouseEvent) => void
|
||||
/** Commit a drag: place `dragId` at `targetIndex` (display order) of `targetGroup`. */
|
||||
onReorderSessions: (dragId: string, targetGroup: string, targetIndex: number) => void
|
||||
searchTerm: string
|
||||
loading?: boolean
|
||||
onNewSession?: () => void
|
||||
@@ -32,6 +42,68 @@ interface SessionTreeProps {
|
||||
onToggleSelection?: (session: Session) => void
|
||||
}
|
||||
|
||||
interface DropTarget {
|
||||
group: string
|
||||
index: number
|
||||
}
|
||||
|
||||
interface PointerDragState {
|
||||
pointerId: number
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
startX: number
|
||||
startY: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
function insertIndexFromY(clientY: number, row: HTMLElement): number {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return clientY < rect.top + rect.height / 2 ? 0 : 1
|
||||
}
|
||||
|
||||
/** Skip the insert line when the drop would leave the row where it already is. */
|
||||
function quietIfNoOp(
|
||||
group: string,
|
||||
insertAt: number,
|
||||
dragId: string,
|
||||
grouped: Record<string, Session[]>
|
||||
): DropTarget | null {
|
||||
const list = grouped[group] || []
|
||||
const dragIndex = list.findIndex((session) => session.id === dragId)
|
||||
if (dragIndex >= 0 && (insertAt === dragIndex || insertAt === dragIndex + 1)) {
|
||||
return null
|
||||
}
|
||||
return { group, index: insertAt }
|
||||
}
|
||||
|
||||
export function resolveSessionDropTarget(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
dragId: string,
|
||||
grouped: Record<string, Session[]>
|
||||
): DropTarget | null {
|
||||
const hit = document.elementFromPoint?.(clientX, clientY)
|
||||
if (!hit || !(hit instanceof Element)) return null
|
||||
|
||||
const row = hit.closest('[data-session-row]') as HTMLElement | null
|
||||
if (row?.dataset.group != null && row.dataset.index != null) {
|
||||
const group = row.dataset.group
|
||||
const index = Number(row.dataset.index)
|
||||
if (Number.isNaN(index)) return null
|
||||
return quietIfNoOp(group, index + insertIndexFromY(clientY, row), dragId, grouped)
|
||||
}
|
||||
|
||||
const zone = (hit.closest('[data-group-row]') ||
|
||||
hit.closest('[data-group-empty]') ||
|
||||
hit.closest('[data-group-body]')) as HTMLElement | null
|
||||
if (zone?.dataset.group) {
|
||||
const group = zone.dataset.group
|
||||
return quietIfNoOp(group, grouped[group]?.length ?? 0, dragId, grouped)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function SessionTree({
|
||||
sessions,
|
||||
extraGroups = [],
|
||||
@@ -43,6 +115,7 @@ export function SessionTree({
|
||||
onContextMenu,
|
||||
onGroupContextMenu,
|
||||
onBlankContextMenu,
|
||||
onReorderSessions,
|
||||
searchTerm,
|
||||
loading = false,
|
||||
onNewSession,
|
||||
@@ -51,6 +124,26 @@ export function SessionTree({
|
||||
onToggleSelection,
|
||||
}: SessionTreeProps) {
|
||||
const { t } = useTranslation('sessions')
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
|
||||
const [preview, setPreview] = useState<{ x: number; y: number; name: string } | null>(null)
|
||||
|
||||
const dragRef = useRef<PointerDragState | null>(null)
|
||||
const groupedRef = useRef<Record<string, Session[]>>({})
|
||||
const expandedRef = useRef(expandedGroups)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
const expandTimerRef = useRef<number | null>(null)
|
||||
const hoverGroupRef = useRef<string | null>(null)
|
||||
|
||||
expandedRef.current = expandedGroups
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.classList.remove(DRAG_BODY_CLASS)
|
||||
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -86,14 +179,123 @@ export function SessionTree({
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(grouped)) {
|
||||
grouped[key].sort(compareBySortOrder)
|
||||
}
|
||||
|
||||
groupedRef.current = grouped
|
||||
|
||||
const groupKeys = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'default') return -1
|
||||
if (b === 'default') return 1
|
||||
return localeCompareText(a, b)
|
||||
})
|
||||
|
||||
// Dragging is a manual-order gesture: disabled while filtering (the visible
|
||||
// list is not the persisted order) and in batch multi-select mode.
|
||||
const dragEnabled = !multiSelect && !query
|
||||
|
||||
const clearExpandTimer = () => {
|
||||
hoverGroupRef.current = null
|
||||
if (expandTimerRef.current != null) {
|
||||
window.clearTimeout(expandTimerRef.current)
|
||||
expandTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const maybeExpandGroup = (group: string) => {
|
||||
if (hoverGroupRef.current === group) return
|
||||
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
|
||||
hoverGroupRef.current = group
|
||||
if (expandedRef.current.has(group)) {
|
||||
expandTimerRef.current = null
|
||||
return
|
||||
}
|
||||
expandTimerRef.current = window.setTimeout(() => {
|
||||
if (!expandedRef.current.has(group)) onToggleGroup(group)
|
||||
}, AUTO_EXPAND_MS)
|
||||
}
|
||||
|
||||
const resetDrag = () => {
|
||||
dragRef.current = null
|
||||
setDragId(null)
|
||||
setDropTarget(null)
|
||||
setPreview(null)
|
||||
clearExpandTimer()
|
||||
document.body.classList.remove(DRAG_BODY_CLASS)
|
||||
}
|
||||
|
||||
const autoScroll = (clientY: number) => {
|
||||
const scroller = scrollerRef.current
|
||||
if (!scroller) return
|
||||
const rect = scroller.getBoundingClientRect()
|
||||
const edge = 28
|
||||
if (clientY < rect.top + edge) scroller.scrollTop -= 10
|
||||
else if (clientY > rect.bottom - edge) scroller.scrollTop += 10
|
||||
}
|
||||
|
||||
const applyPointerHover = (clientX: number, clientY: number, sessionId: string) => {
|
||||
const target = resolveSessionDropTarget(clientX, clientY, sessionId, groupedRef.current)
|
||||
setDropTarget(target)
|
||||
if (target) maybeExpandGroup(target.group)
|
||||
else clearExpandTimer()
|
||||
autoScroll(clientY)
|
||||
}
|
||||
|
||||
const startPointerDrag = (session: Session, event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!dragEnabled || event.button !== 0) return
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
const movePointerDrag = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const state = dragRef.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
|
||||
if (!state.active) {
|
||||
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
|
||||
if (distance < POINTER_DRAG_THRESHOLD) return
|
||||
state.active = true
|
||||
suppressClickRef.current = true
|
||||
setDragId(state.sessionId)
|
||||
document.body.classList.add(DRAG_BODY_CLASS)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setPreview({ x: event.clientX, y: event.clientY, name: state.sessionName })
|
||||
applyPointerHover(event.clientX, event.clientY, state.sessionId)
|
||||
}
|
||||
|
||||
const finishPointerDrag = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const state = dragRef.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
if (state.active) {
|
||||
event.preventDefault()
|
||||
const target = resolveSessionDropTarget(event.clientX, event.clientY, state.sessionId, groupedRef.current)
|
||||
if (target) {
|
||||
onReorderSessions(state.sessionId, target.group, target.index)
|
||||
if (!expandedRef.current.has(target.group)) onToggleGroup(target.group)
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
resetDrag()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
|
||||
@@ -103,11 +305,13 @@ export function SessionTree({
|
||||
{groupKeys.map((group) => {
|
||||
const groupSessions = grouped[group]
|
||||
const isExpanded = expandedGroups.has(group)
|
||||
const groupDropActive = dropTarget?.group === group && dropTarget.index === groupSessions.length
|
||||
return (
|
||||
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
|
||||
<div key={group} className="border-dark-700/20 border-b last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
data-group-row
|
||||
data-group={group}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={`session-group-${group}`}
|
||||
onClick={() => onToggleGroup(group)}
|
||||
@@ -116,15 +320,17 @@ export function SessionTree({
|
||||
e.stopPropagation()
|
||||
onGroupContextMenu?.(e, group)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-txt-muted hover:bg-dark-700/50 transition-colors"
|
||||
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
|
||||
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className="w-4 h-4 transition-transform duration-200 ease-in-out"
|
||||
className="h-4 w-4 transition-transform duration-200 ease-in-out"
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
<Folder className="w-4 h-4" />
|
||||
<Folder className="h-4 w-4" />
|
||||
<span className="flex-1 text-left">{groupDisplayName(group)}</span>
|
||||
<span className="text-xs text-txt-faint tabular-nums">{groupSessions.length}</span>
|
||||
<span className="text-xs tabular-nums text-txt-faint">{groupSessions.length}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
@@ -133,26 +339,44 @@ export function SessionTree({
|
||||
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
||||
aria-hidden={!isExpanded}
|
||||
>
|
||||
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
data-group-body={group}
|
||||
data-group={group}
|
||||
{...(!isExpanded ? { inert: '' } : {})}
|
||||
>
|
||||
{groupSessions.length === 0 ? (
|
||||
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
|
||||
<div className="px-9 py-2 text-[11px] text-txt-faint" data-group-empty data-group={group}>
|
||||
{t('tree.emptyTitle')}
|
||||
</div>
|
||||
) : (
|
||||
groupSessions.map((session) => {
|
||||
const selected = multiSelect
|
||||
? selectedSessionIds.has(session.id)
|
||||
: selectedSessionId === session.id
|
||||
groupSessions.map((session, index) => {
|
||||
const selected = multiSelect ? selectedSessionIds.has(session.id) : selectedSessionId === session.id
|
||||
const showInsertBefore = dropTarget?.group === group && dropTarget.index === index
|
||||
const showInsertAfter =
|
||||
dropTarget?.group === group &&
|
||||
dropTarget.index === index + 1 &&
|
||||
index === groupSessions.length - 1
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
data-session-row
|
||||
data-group={group}
|
||||
data-index={index}
|
||||
role="button"
|
||||
tabIndex={isExpanded ? 0 : -1}
|
||||
onPointerDown={(e) => startPointerDrag(session, e)}
|
||||
onPointerMove={movePointerDrag}
|
||||
onPointerUp={finishPointerDrag}
|
||||
onPointerCancel={finishPointerDrag}
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) return
|
||||
if (multiSelect) onToggleSelection?.(session)
|
||||
else onSelectSession(session)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!multiSelect) onConnectSession(session)
|
||||
if (suppressClickRef.current || multiSelect) return
|
||||
onConnectSession(session)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
|
||||
@@ -167,22 +391,24 @@ export function SessionTree({
|
||||
? t('tree.selectSession')
|
||||
: `${session.username}@${session.host}:${session.port || 22}`
|
||||
}
|
||||
className={`
|
||||
group flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer
|
||||
transition-colors duration-150 pl-9 pr-2
|
||||
${
|
||||
className={`group relative flex touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragEnabled ? 'cursor-grab' : 'cursor-pointer'} ${dragId === session.id ? 'opacity-40' : ''} ${
|
||||
selected
|
||||
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
|
||||
: 'border-l-2 border-transparent hover:bg-dark-700/40'
|
||||
}
|
||||
`}
|
||||
? 'border-l-2 border-accent-blue bg-accent-blue/10'
|
||||
: 'hover:bg-dark-700/40 border-l-2 border-transparent'
|
||||
} `}
|
||||
>
|
||||
{(showInsertBefore || showInsertAfter) && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-x-1 ${
|
||||
showInsertBefore ? 'top-0' : 'bottom-0'
|
||||
} h-0.5 rounded-full bg-accent-blue`}
|
||||
/>
|
||||
)}
|
||||
{multiSelect ? (
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
|
||||
selected
|
||||
? 'border-accent-blue bg-accent-blue text-white'
|
||||
: 'border-dark-500 bg-dark-900'
|
||||
selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-900'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -210,11 +436,7 @@ export function SessionTree({
|
||||
})}
|
||||
|
||||
{filteredSessions.length === 0 && query && (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title={t('tree.noMatchTitle')}
|
||||
description={t('tree.noMatchDesc')}
|
||||
/>
|
||||
<EmptyState icon={ServerOff} title={t('tree.noMatchTitle')} description={t('tree.noMatchDesc')} />
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && (
|
||||
@@ -222,11 +444,21 @@ export function SessionTree({
|
||||
icon={ServerOff}
|
||||
title={t('tree.emptyTitle')}
|
||||
description={t('tree.emptyDesc')}
|
||||
action={
|
||||
onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined
|
||||
}
|
||||
action={onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preview &&
|
||||
createPortal(
|
||||
<div
|
||||
aria-hidden
|
||||
className="bg-dark-800/95 pointer-events-none fixed z-[180] max-w-[12rem] truncate rounded-md border border-accent-blue/40 px-2 py-1 text-xs text-content-primary shadow-lg"
|
||||
style={{ left: preview.x + 12, top: preview.y + 12 }}
|
||||
>
|
||||
{preview.name}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
* TabItem — local shell uses the same Terminal icon as the launcher (+)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Tab } from '../../../stores/tabStore'
|
||||
import { useSessionStore } from '../../../stores/sessionStore'
|
||||
import HostDistroIcon from '../../common/HostDistroIcon'
|
||||
import { resolveTabTitle } from '../../../utils/tabTitle'
|
||||
import StatusDot from '../../common/StatusDot'
|
||||
|
||||
interface TabItemProps {
|
||||
tab: Tab
|
||||
@@ -22,6 +24,8 @@ interface TabItemProps {
|
||||
onDragOver: (e: React.DragEvent, index: number) => void
|
||||
onDrop: (e: React.DragEvent, index: number) => void
|
||||
onDragEnd: () => void
|
||||
/** 双击标签内联重命名;welcome/settings 等固定标签不传。 */
|
||||
onRename?: (title: string) => void
|
||||
}
|
||||
|
||||
export function TabItem({
|
||||
@@ -37,6 +41,7 @@ export function TabItem({
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onRename,
|
||||
}: TabItemProps) {
|
||||
const { t } = useTranslation(['terminal', 'common'])
|
||||
const displayTitle = resolveTabTitle(tab)
|
||||
@@ -44,6 +49,29 @@ export function TabItem({
|
||||
const session = useSessionStore((state) =>
|
||||
tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined
|
||||
)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
renameInputRef.current?.focus()
|
||||
renameInputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const startRename = () => {
|
||||
setDraft(tab.title)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
setEditing(false)
|
||||
const next = draft.trim()
|
||||
if (next && next !== tab.title) {
|
||||
onRename?.(next)
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
if (isLocalTerm) {
|
||||
@@ -57,13 +85,8 @@ export function TabItem({
|
||||
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
|
||||
return (
|
||||
<HostDistroIcon
|
||||
distro={
|
||||
session?.distro ??
|
||||
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
|
||||
}
|
||||
protocol={
|
||||
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
|
||||
}
|
||||
distro={session?.distro ?? (tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)}
|
||||
protocol={tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : (session?.protocol ?? 'ssh')}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
@@ -88,12 +111,10 @@ export function TabItem({
|
||||
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
|
||||
|
||||
const statusDot =
|
||||
tab.connectionStatus === 'connecting' ? (
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
|
||||
) : tab.connectionStatus === 'connected' ? (
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
|
||||
) : tab.connectionStatus === 'error' ? (
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
|
||||
tab.connectionStatus === 'connecting' ||
|
||||
tab.connectionStatus === 'connected' ||
|
||||
tab.connectionStatus === 'error' ? (
|
||||
<StatusDot state={tab.connectionStatus} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
@@ -101,7 +122,8 @@ export function TabItem({
|
||||
data-tab-item
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
draggable
|
||||
onClick={onActivate}
|
||||
onAuxClick={(e) => {
|
||||
@@ -126,50 +148,66 @@ export function TabItem({
|
||||
onDragOver={(e) => onDragOver(e, index)}
|
||||
onDrop={(e) => onDrop(e, index)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2
|
||||
border-r border-dark-700/20 px-3 py-2 titlebar-no-drag transition-all duration-150
|
||||
${
|
||||
isActive
|
||||
? 'bg-dark-900 text-content-primary'
|
||||
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
||||
}
|
||||
${isDragging ? 'opacity-50' : 'opacity-100'}
|
||||
`}
|
||||
className={`border-dark-700/20 titlebar-no-drag group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2 border-r px-3 py-2 transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-dark-900 text-content-primary'
|
||||
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
|
||||
} ${isDragging ? 'opacity-50' : 'opacity-100'} `}
|
||||
>
|
||||
{showLeftIndicator && (
|
||||
<div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />
|
||||
)}
|
||||
{showRightIndicator && (
|
||||
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
|
||||
)}
|
||||
{showLeftIndicator && <div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />}
|
||||
{showRightIndicator && <div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />}
|
||||
|
||||
{statusDot}
|
||||
{getIcon()}
|
||||
|
||||
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('common:tab.renameAria', { title: displayTitle })}
|
||||
className="ui-input-sm min-w-0 flex-1 px-1 py-0 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex-1 truncate text-sm font-medium"
|
||||
title={onRename ? t('common:tab.renameHint') : displayTitle}
|
||||
onDoubleClick={(e) => {
|
||||
if (!onRename) return
|
||||
e.stopPropagation()
|
||||
startRename()
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common:tab.closeAria', { title: displayTitle })}
|
||||
className={`
|
||||
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
|
||||
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
|
||||
${isActive ? 'opacity-100' : ''}
|
||||
`}
|
||||
className={`rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 ${isActive ? 'opacity-100' : ''} `}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`
|
||||
absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue
|
||||
transition-[opacity,transform] duration-150
|
||||
${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'}
|
||||
`}
|
||||
className={`absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue transition-[opacity,transform] duration-150 ${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'} `}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,22 +10,19 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
sessionProtocol,
|
||||
} from '../../utils/sessionProtocol'
|
||||
import { buildOpenTabPayload, defaultPortForProtocol, sessionProtocol } from '../../utils/sessionProtocol'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
onRdpConnect?: () => void
|
||||
onVncConnect?: () => void
|
||||
onImportSessions?: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
|
||||
<kbd className="border-dark-600/80 bg-dark-900/80 rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
@@ -36,6 +33,7 @@ export default function WelcomeView({
|
||||
onQuickConnect,
|
||||
onRdpConnect,
|
||||
onVncConnect,
|
||||
onImportSessions,
|
||||
}: WelcomeViewProps) {
|
||||
const { t } = useTranslation('sessions')
|
||||
const { addTab } = useTabStore()
|
||||
@@ -71,15 +69,11 @@ export default function WelcomeView({
|
||||
}, [shellMenuOpen])
|
||||
|
||||
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
|
||||
const defaultShellLabel = defaultShell
|
||||
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
|
||||
: 'Shell'
|
||||
const defaultShellLabel = defaultShell ? shellDisplayFromPath(defaultShell.path || defaultShell.name).title : 'Shell'
|
||||
|
||||
const openDefaultLocal = useCallback(() => {
|
||||
openLocalShellTab(
|
||||
defaultShell
|
||||
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
|
||||
: undefined
|
||||
defaultShell ? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name } : undefined
|
||||
)
|
||||
}, [defaultShell])
|
||||
|
||||
@@ -112,7 +106,7 @@ export default function WelcomeView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
|
||||
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
@@ -129,7 +123,7 @@ export default function WelcomeView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
|
||||
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
|
||||
@@ -144,7 +138,7 @@ export default function WelcomeView({
|
||||
</button>
|
||||
|
||||
<div ref={shellMenuRef} className="relative">
|
||||
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
|
||||
<div className="border-dark-700/60 bg-dark-800/50 flex h-full overflow-hidden rounded-xl border transition-colors hover:border-dark-600 hover:bg-dark-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDefaultLocal}
|
||||
@@ -158,9 +152,7 @@ export default function WelcomeView({
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
|
||||
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
|
||||
{defaultShellLabel}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">{defaultShellLabel}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -168,14 +160,14 @@ export default function WelcomeView({
|
||||
aria-label={t('welcome.localShell.selectShell')}
|
||||
aria-expanded={shellMenuOpen}
|
||||
onClick={() => setShellMenuOpen((v) => !v)}
|
||||
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
|
||||
className="border-dark-700/60 hover:bg-dark-700/40 flex w-9 flex-shrink-0 items-center justify-center border-l text-txt-faint transition-colors hover:text-content-secondary"
|
||||
>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shellMenuOpen && shells.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
|
||||
<div className="border-dark-600/80 absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border bg-dark-800 py-1 shadow-xl">
|
||||
{shells.map((s) => (
|
||||
<button
|
||||
key={s.path}
|
||||
@@ -184,7 +176,7 @@ export default function WelcomeView({
|
||||
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
|
||||
setShellMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
|
||||
className="hover:bg-dark-700/70 flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
||||
>
|
||||
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
|
||||
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
|
||||
@@ -201,7 +193,9 @@ export default function WelcomeView({
|
||||
|
||||
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('welcome.sectionDesktop')}</h2>
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('welcome.sectionDesktop')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
@@ -210,7 +204,7 @@ export default function WelcomeView({
|
||||
if (!requirePro('rdp')) return
|
||||
onRdpConnect?.()
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Monitor className="h-4 w-4" />
|
||||
@@ -226,7 +220,7 @@ export default function WelcomeView({
|
||||
if (!requirePro('vnc')) return
|
||||
onVncConnect?.()
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Cast className="h-4 w-4" />
|
||||
@@ -245,34 +239,29 @@ export default function WelcomeView({
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('welcome.sectionRecent')}
|
||||
</h2>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
|
||||
)}
|
||||
{recentSessions.length > 0 && <span className="text-[11px] text-txt-dim">{recentSessions.length}</span>}
|
||||
</div>
|
||||
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
|
||||
<div className="border-dark-700/70 rounded-xl border border-dashed px-4 py-10">
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title={t('welcome.recentEmpty.title')}
|
||||
description={t('welcome.recentEmpty.desc')}
|
||||
action={onImportSessions ? { label: t('welcome.import.title'), onClick: onImportSessions } : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
|
||||
<ul className="divide-dark-700/40 border-dark-700/50 divide-y overflow-hidden rounded-xl border">
|
||||
{recentSessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
||||
className="hover:bg-dark-800/80 group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors"
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
|
||||
<HostDistroIcon
|
||||
distro={session.distro}
|
||||
protocol={session.protocol}
|
||||
size="md"
|
||||
/>
|
||||
<HostDistroIcon distro={session.distro} protocol={session.protocol} size="md" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
@@ -282,9 +271,7 @@ export default function WelcomeView({
|
||||
</span>
|
||||
</span>
|
||||
{session.group ? (
|
||||
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
|
||||
{session.group}
|
||||
</span>
|
||||
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">{session.group}</span>
|
||||
) : null}
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import { Check, CheckCircle2, ChevronDown, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import {
|
||||
AI_PROVIDER_FAMILY_ORDER,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveProviderFamily,
|
||||
} from '../../ai/providerPresets'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
import Button from '../common/Button'
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
||||
|
||||
@@ -133,7 +134,15 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
const id = `manual-${Date.now().toString(36)}`
|
||||
updateDraft({
|
||||
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
|
||||
models: [
|
||||
{
|
||||
id,
|
||||
model: modelId,
|
||||
label: modelId || t('ai.customModel'),
|
||||
capability: 'chat',
|
||||
billing: defaultManualBilling(),
|
||||
},
|
||||
],
|
||||
defaultModelId: id,
|
||||
})
|
||||
}
|
||||
@@ -208,13 +217,15 @@ export default function AiProviderSettings() {
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
|
||||
</button>
|
||||
{t('ai.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
|
||||
@@ -233,7 +244,7 @@ export default function AiProviderSettings() {
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'text-content-secondary hover:bg-dark-700/50'
|
||||
: 'hover:bg-dark-700/50 text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -256,7 +267,11 @@ export default function AiProviderSettings() {
|
||||
<div className="max-w-xl space-y-3.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.name')}>
|
||||
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
@@ -286,7 +301,11 @@ export default function AiProviderSettings() {
|
||||
</div>
|
||||
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent-blue hover:underline"
|
||||
onClick={() => setManualModel(false)}
|
||||
>
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
)}
|
||||
@@ -298,7 +317,13 @@ export default function AiProviderSettings() {
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
|
||||
placeholder={
|
||||
draft.hasApiKey
|
||||
? t('ai.savedKeepBlank')
|
||||
: draft.authStyle === 'none'
|
||||
? t('ai.notRequired')
|
||||
: 'sk-...'
|
||||
}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
@@ -330,7 +355,9 @@ export default function AiProviderSettings() {
|
||||
<Field label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
|
||||
onChange={(event) =>
|
||||
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
@@ -341,7 +368,9 @@ export default function AiProviderSettings() {
|
||||
<Field label={t('ai.auth')}>
|
||||
<select
|
||||
value={draft.authStyle}
|
||||
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
|
||||
onChange={(event) =>
|
||||
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="bearer">Bearer</option>
|
||||
@@ -362,31 +391,45 @@ export default function AiProviderSettings() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void testConnection()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
className="ui-btn-secondary flex items-center gap-1.5"
|
||||
loading={testState === 'testing'}
|
||||
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
|
||||
onClick={() => void testConnection()}
|
||||
>
|
||||
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
|
||||
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!verified || saving}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
loading={saving}
|
||||
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{t('common:action.save')}
|
||||
</button>
|
||||
</Button>
|
||||
{isStored && store.activeProviderId !== draft.id && (
|
||||
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void store.setActiveProvider(draft.id)}
|
||||
>
|
||||
{t('ai.setActive')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300">
|
||||
<button
|
||||
type="button"
|
||||
title={t('ai.deleteTitle')}
|
||||
onClick={() => void remove()}
|
||||
className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function RemoteTextEditor({
|
||||
aria-modal="true"
|
||||
aria-label={t('editor.title', { name: fileName })}
|
||||
>
|
||||
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
|
||||
<header className="border-dark-700/60 flex flex-shrink-0 items-center gap-3 border-b bg-dark-800 px-4 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
|
||||
<FileCode className="h-4 w-4" />
|
||||
@@ -216,9 +216,7 @@ export default function RemoteTextEditor({
|
||||
disabled={!dirty || saving || loading}
|
||||
onClick={() => void handleSave()}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
|
||||
dirty
|
||||
? 'bg-accent-blue text-white hover:bg-blue-500'
|
||||
: 'bg-dark-700 text-txt-muted'
|
||||
dirty ? 'bg-accent-blue text-white hover:bg-blue-500' : 'bg-dark-700 text-txt-muted'
|
||||
}`}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
@@ -242,7 +240,7 @@ export default function RemoteTextEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
|
||||
<div className={`min-h-0 flex-1 ${effective === 'light' ? 'bg-white' : 'bg-[#1e1e1e]'}`}>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
|
||||
{t('common:status.loading')}
|
||||
@@ -271,7 +269,7 @@ export default function RemoteTextEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
|
||||
<footer className="border-dark-700/60 flex flex-shrink-0 items-center justify-between gap-3 border-t bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
|
||||
<span>{t('editor.footer')}</span>
|
||||
<span>
|
||||
{t('editor.charCount', { count: content.length.toLocaleString() })}
|
||||
|
||||
@@ -7,16 +7,23 @@ export interface ShellClosedOverlayProps {
|
||||
reason: string
|
||||
idleTimeout: boolean
|
||||
restarting?: boolean
|
||||
isLocal?: boolean
|
||||
onReopen: () => void
|
||||
}
|
||||
|
||||
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
|
||||
export function ShellClosedOverlay({
|
||||
reason,
|
||||
idleTimeout,
|
||||
restarting = false,
|
||||
isLocal = false,
|
||||
onReopen,
|
||||
}: ShellClosedOverlayProps) {
|
||||
const { t } = useTranslation('terminal')
|
||||
|
||||
return (
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<div className="max-w-sm px-5 text-center">
|
||||
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
|
||||
{idleTimeout ? (
|
||||
<Clock3 className="h-6 w-6 text-accent-blue" />
|
||||
) : (
|
||||
@@ -27,7 +34,9 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
|
||||
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
|
||||
<div className="mb-4 text-[11px] text-accent-green">{t('overlay.shellClosedHint')}</div>
|
||||
<div className="mb-4 text-[11px] text-accent-green">
|
||||
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={restarting}
|
||||
@@ -48,6 +57,7 @@ export interface TerminalOverlayProps {
|
||||
showSuspendedOverlay: boolean
|
||||
needsReconnect: boolean
|
||||
reconnectAttempt?: number
|
||||
isLocal?: boolean
|
||||
onReconnect: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
@@ -58,6 +68,7 @@ function TerminalOverlay({
|
||||
showSuspendedOverlay,
|
||||
needsReconnect,
|
||||
reconnectAttempt = 0,
|
||||
isLocal = false,
|
||||
onReconnect,
|
||||
onResume,
|
||||
}: TerminalOverlayProps) {
|
||||
@@ -69,17 +80,21 @@ function TerminalOverlay({
|
||||
{showDisconnectBanner && (
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<div className="max-w-sm px-6 text-center">
|
||||
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
|
||||
<WifiOff className="h-7 w-7 text-accent-blue" />
|
||||
</div>
|
||||
<div className="mb-1.5 text-base font-medium text-slate-100">{t('overlay.disconnected')}</div>
|
||||
<div className="mb-1.5 text-base font-medium text-slate-100">
|
||||
{t(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')}
|
||||
</div>
|
||||
<div className="mb-6 text-sm leading-relaxed text-slate-400">
|
||||
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
|
||||
{statusMessage?.trim()
|
||||
? statusMessage
|
||||
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm shadow-accent-blue/20 transition-colors hover:bg-accent-blue-hover"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('overlay.reconnect')}
|
||||
@@ -121,7 +136,7 @@ function TerminalOverlay({
|
||||
</div>
|
||||
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
|
||||
<div className="mb-5 text-sm leading-relaxed text-slate-400">
|
||||
{statusMessage || t('overlay.connectFailedHint')}
|
||||
{statusMessage || t(isLocal ? 'overlay.localConnectFailedHint' : 'overlay.connectFailedHint')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -142,9 +157,7 @@ function TerminalOverlay({
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
{connectionState === 'reconnecting'
|
||||
? t('overlay.reconnecting', {
|
||||
attempt: reconnectAttempt
|
||||
? t('overlay.reconnectingAttempt', { count: reconnectAttempt })
|
||||
: '',
|
||||
attempt: reconnectAttempt ? t('overlay.reconnectingAttempt', { count: reconnectAttempt }) : '',
|
||||
})
|
||||
: t('overlay.connecting')}
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,7 @@ import { createLogger } from '../../utils/logger'
|
||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import {
|
||||
ZmodemSftpBridge,
|
||||
type ZmodemBridgeStatus,
|
||||
} from '../../utils/zmodemSftpBridge'
|
||||
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
|
||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
||||
import TerminalOverlay from './TerminalOverlay'
|
||||
@@ -311,7 +308,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
|
||||
|
||||
{zmodemBanner && (
|
||||
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
|
||||
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
|
||||
{zmodemBanner}
|
||||
</div>
|
||||
)}
|
||||
@@ -337,6 +334,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
isLocal={!!currentTab?.isLocal}
|
||||
onReconnect={handleReconnect}
|
||||
onResume={resumeConnection}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
|
||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
||||
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
|
||||
import { shellWrite, shellResize, shellCreate, shellKill, flushShellBootstrap } from '../../utils/shellIO'
|
||||
import { buildTerminalFontFamily } from '../../utils/terminalFont'
|
||||
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
|
||||
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
|
||||
@@ -48,10 +48,7 @@ import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import {
|
||||
ZmodemSftpBridge,
|
||||
type ZmodemBridgeStatus,
|
||||
} from '../../utils/zmodemSftpBridge'
|
||||
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
|
||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||
import TerminalContextMenu from './TerminalContextMenu'
|
||||
import i18n from '../../i18n'
|
||||
@@ -78,6 +75,8 @@ interface PaneRuntime {
|
||||
pasteClipboard: () => void
|
||||
selectAll: () => void
|
||||
clear: () => void
|
||||
/** 写入命令文本但不带回车:插入到当前输入行,由用户自行检查后执行。 */
|
||||
insertText: (text: string) => boolean
|
||||
}
|
||||
|
||||
function captureTerminal(term: Terminal): TerminalAiContext {
|
||||
@@ -220,11 +219,7 @@ function PaneTerminal({
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onContextMenuRef.current(
|
||||
paneId,
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
term.hasSelection()
|
||||
)
|
||||
onContextMenuRef.current(paneId, { x: event.clientX, y: event.clientY }, term.hasSelection())
|
||||
}
|
||||
container.addEventListener('contextmenu', handleContextMenu)
|
||||
|
||||
@@ -250,22 +245,21 @@ function PaneTerminal({
|
||||
},
|
||||
onError: (error) => logger.warn('终端输入失败', error),
|
||||
})
|
||||
const zmodem =
|
||||
isLocal
|
||||
? null
|
||||
: new ZmodemSftpBridge({
|
||||
writeToTerminal: (data) => {
|
||||
if (disposed) return
|
||||
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
|
||||
},
|
||||
sendToPeer: (data) => {
|
||||
const sid = shellIdRef.current
|
||||
if (!sid) return
|
||||
return shellWrite(sid, data)
|
||||
},
|
||||
getConnectionId: () => connectionIdRef.current,
|
||||
onStatus: setZmodemStatus,
|
||||
})
|
||||
const zmodem = isLocal
|
||||
? null
|
||||
: new ZmodemSftpBridge({
|
||||
writeToTerminal: (data) => {
|
||||
if (disposed) return
|
||||
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
|
||||
},
|
||||
sendToPeer: (data) => {
|
||||
const sid = shellIdRef.current
|
||||
if (!sid) return
|
||||
return shellWrite(sid, data)
|
||||
},
|
||||
getConnectionId: () => connectionIdRef.current,
|
||||
onStatus: setZmodemStatus,
|
||||
})
|
||||
term.onData((data) => {
|
||||
if (disposed) return
|
||||
zmodem?.noteInput(data)
|
||||
@@ -305,9 +299,17 @@ function PaneTerminal({
|
||||
getTerminal: () => xtermRef.current,
|
||||
getSearchAddon: () => searchAddonRef.current,
|
||||
copySelection,
|
||||
pasteClipboard: () => { void pasteClipboard() },
|
||||
pasteClipboard: () => {
|
||||
void pasteClipboard()
|
||||
},
|
||||
selectAll: () => term.selectAll(),
|
||||
clear: () => safeTerminalClear(term),
|
||||
insertText: (text) => {
|
||||
if (!shellIdRef.current || !text) return false
|
||||
// xterm input() 走 onData → pipeline,与用户键入同一条路径且不带回车。
|
||||
term.input(text, true)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -356,6 +358,7 @@ function PaneTerminal({
|
||||
const pendingId = isLocal
|
||||
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
shellIdRef.current = pendingId
|
||||
void (async () => {
|
||||
try {
|
||||
const cols = xtermRef.current?.cols || 80
|
||||
@@ -377,18 +380,17 @@ function PaneTerminal({
|
||||
}
|
||||
shellIdRef.current = result.shellId
|
||||
onShellReady(paneId, result.shellId)
|
||||
// Secondary panes must open output or MOTD stays buffered and the
|
||||
// reader never emits ssh:shell-data (blank cursor forever).
|
||||
if (!isLocal) {
|
||||
try {
|
||||
const boot = await flushSshShellBootstrap(result.shellId)
|
||||
if (boot && xtermRef.current) xtermRef.current.write(boot)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to flush split-pane shell bootstrap', err)
|
||||
}
|
||||
// Open buffered PTY output or the first PS1 / MOTD is dropped.
|
||||
try {
|
||||
const boot = await flushShellBootstrap(result.shellId)
|
||||
if (boot && xtermRef.current) xtermRef.current.write(boot)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to flush split-pane shell bootstrap', err)
|
||||
}
|
||||
} else {
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`)
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('create shell failed', e)
|
||||
@@ -419,10 +421,10 @@ function PaneTerminal({
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
|
||||
<div className={`relative h-full w-full ${isFocused ? 'ring-1 ring-accent-blue/40' : ''}`} onMouseDown={onFocus}>
|
||||
<div ref={containerRef} className="terminal-surface h-full w-full" />
|
||||
{zmodemBanner && (
|
||||
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
|
||||
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
|
||||
{zmodemBanner}
|
||||
</div>
|
||||
)}
|
||||
@@ -461,15 +463,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
const broadcastTargetCount = useTerminalBroadcastStore(
|
||||
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
|
||||
)
|
||||
const readyPaneCount = useMemo(
|
||||
() => panes.filter((pane) => !!pane.shellId).length,
|
||||
[panes]
|
||||
)
|
||||
const readyPaneCount = useMemo(() => panes.filter((pane) => !!pane.shellId).length, [panes])
|
||||
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
|
||||
const effectiveBroadcastCount =
|
||||
broadcastScope === 'current-tab'
|
||||
? Math.max(broadcastTargetCount, readyPaneCount)
|
||||
: broadcastTargetCount
|
||||
broadcastScope === 'current-tab' ? Math.max(broadcastTargetCount, readyPaneCount) : broadcastTargetCount
|
||||
const canEnableBroadcast = effectiveBroadcastCount >= 2
|
||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
@@ -546,34 +543,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
})
|
||||
}, [tabId, direction, panes.length, ratio, updateTab])
|
||||
|
||||
// Sync primary shell from connection hook → first pane
|
||||
// Sync primary shell from connection hook → first pane.
|
||||
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
|
||||
useEffect(() => {
|
||||
if (connectionState === 'connected' && shellIdRef.current) {
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
if (prev[0].shellId === shellIdRef.current) return prev
|
||||
return prev.map((p, i) => (i === 0 ? { ...p, shellId: shellIdRef.current } : p))
|
||||
})
|
||||
}
|
||||
}, [connectionState, shellIdRef])
|
||||
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
|
||||
if (!sid) return
|
||||
if (connectionState !== 'connected' && connectionState !== 'connecting') return
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
if (prev[0].shellId === sid) return prev
|
||||
if (prev[0].shellId) return prev
|
||||
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
|
||||
})
|
||||
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
|
||||
|
||||
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
|
||||
useEffect(() => {
|
||||
const connectionId = connectionIdRef.current
|
||||
const targets = connectionState === 'connected' && connectionId
|
||||
? panes.flatMap((pane) => pane.shellId
|
||||
? [{
|
||||
tabId,
|
||||
paneId: pane.id,
|
||||
shellId: pane.shellId,
|
||||
connectionId,
|
||||
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
|
||||
sessionId: currentTab?.sessionId,
|
||||
}]
|
||||
: [])
|
||||
: []
|
||||
const targets =
|
||||
connectionState === 'connected' && connectionId
|
||||
? panes.flatMap((pane) =>
|
||||
pane.shellId
|
||||
? [
|
||||
{
|
||||
tabId,
|
||||
paneId: pane.id,
|
||||
shellId: pane.shellId,
|
||||
connectionId,
|
||||
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
|
||||
sessionId: currentTab?.sessionId,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
||||
: []
|
||||
registerBroadcastWorkspace({ tabId, targets })
|
||||
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId, t])
|
||||
}, [
|
||||
connectionIdRef,
|
||||
connectionState,
|
||||
currentTab?.isLocal,
|
||||
currentTab?.sessionId,
|
||||
currentTab?.title,
|
||||
panes,
|
||||
registerBroadcastWorkspace,
|
||||
tabId,
|
||||
t,
|
||||
])
|
||||
|
||||
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
|
||||
|
||||
@@ -721,17 +736,20 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
const unregisterShellHandler = useCallback((paneId: string) => {
|
||||
shellHandlersRef.current.delete(paneId)
|
||||
}, [])
|
||||
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
|
||||
paneRuntimesRef.current.set(paneId, runtime)
|
||||
if (panesRef.current[0]?.id === paneId) {
|
||||
primaryXtermRef.current = runtime.getTerminal()
|
||||
armInitialConnect()
|
||||
}
|
||||
if (paneId === focusedPaneId) {
|
||||
focusedXtermRef.current = runtime.getTerminal()
|
||||
focusedSearchAddonRef.current = runtime.getSearchAddon()
|
||||
}
|
||||
}, [armInitialConnect, focusedPaneId])
|
||||
const registerRuntime = useCallback(
|
||||
(paneId: string, runtime: PaneRuntime) => {
|
||||
paneRuntimesRef.current.set(paneId, runtime)
|
||||
if (panesRef.current[0]?.id === paneId) {
|
||||
primaryXtermRef.current = runtime.getTerminal()
|
||||
armInitialConnect()
|
||||
}
|
||||
if (paneId === focusedPaneId) {
|
||||
focusedXtermRef.current = runtime.getTerminal()
|
||||
focusedSearchAddonRef.current = runtime.getSearchAddon()
|
||||
}
|
||||
},
|
||||
[armInitialConnect, focusedPaneId]
|
||||
)
|
||||
const unregisterRuntime = useCallback((paneId: string) => {
|
||||
paneRuntimesRef.current.delete(paneId)
|
||||
if (panesRef.current[0]?.id === paneId) {
|
||||
@@ -802,7 +820,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
preferredId
|
||||
)
|
||||
if (!result.success || !result.shellId) {
|
||||
throw new Error(result.error || (isLocal ? i18n.t('terminal:zmodem.createLocalShellFailed') : i18n.t('terminal:zmodem.createRemoteShellFailed')))
|
||||
throw new Error(
|
||||
result.error ||
|
||||
(isLocal
|
||||
? i18n.t('terminal:zmodem.createLocalShellFailed')
|
||||
: i18n.t('terminal:zmodem.createRemoteShellFailed'))
|
||||
)
|
||||
}
|
||||
if (!isLocal) {
|
||||
getConnectionStore().addShell(connectionId, result.shellId)
|
||||
@@ -819,14 +842,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
shellId: result.shellId,
|
||||
})
|
||||
}
|
||||
if (!isLocal && result.shellId) {
|
||||
try {
|
||||
const boot = await flushSshShellBootstrap(result.shellId)
|
||||
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
|
||||
if (boot && term) term.write(boot)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to flush reopened shell bootstrap', err)
|
||||
}
|
||||
try {
|
||||
const boot = await flushShellBootstrap(result.shellId)
|
||||
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
|
||||
if (boot && term) term.write(boot)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to flush reopened shell bootstrap', err)
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
@@ -839,16 +860,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
)
|
||||
}
|
||||
},
|
||||
[connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.localShellPath, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
|
||||
[
|
||||
connectionIdRef,
|
||||
connectionState,
|
||||
currentTab?.isLocal,
|
||||
currentTab?.localShellPath,
|
||||
focusedPaneId,
|
||||
shellIdRef,
|
||||
tabId,
|
||||
updateTabConnectionInstance,
|
||||
]
|
||||
)
|
||||
|
||||
const handlePaneInput = useCallback(
|
||||
(sourcePaneId: string, data: string) => {
|
||||
const shouldBroadcast = shouldBroadcastTerminalInput(
|
||||
broadcastInput,
|
||||
currentTab?.isLocal,
|
||||
connectionIdRef.current
|
||||
)
|
||||
const shouldBroadcast = shouldBroadcastTerminalInput(broadcastInput, currentTab?.isLocal, connectionIdRef.current)
|
||||
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
|
||||
const targets = shouldBroadcast
|
||||
? broadcastTargets.map((target) => target.shellId)
|
||||
@@ -919,87 +945,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
||||
}, [addPane, direction, isActive, setSplitDirection])
|
||||
|
||||
const closePane = useCallback((paneId: string) => {
|
||||
if (panesRef.current.length <= 1) return
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
const connectionId = connectionIdRef.current
|
||||
if (pane?.shellId) {
|
||||
const closingShellId = pane.shellId
|
||||
void shellKill(closingShellId)
|
||||
.then((result) => {
|
||||
const closePane = useCallback(
|
||||
(paneId: string) => {
|
||||
if (panesRef.current.length <= 1) return
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
const connectionId = connectionIdRef.current
|
||||
if (pane?.shellId) {
|
||||
const closingShellId = pane.shellId
|
||||
void shellKill(closingShellId)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
logger.warn('关闭分屏 Shell 失败', {
|
||||
shellId: closingShellId,
|
||||
error: result.error || 'unknown',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().removeShell(connectionId, pane.shellId)
|
||||
}
|
||||
}
|
||||
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
||||
setPanes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== paneId)
|
||||
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
||||
setFocusedPaneId(next[0].id)
|
||||
if (next[0].shellId) {
|
||||
shellIdRef.current = next[0].shellId
|
||||
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
|
||||
)
|
||||
|
||||
const closePaneOrTab = useCallback(
|
||||
(paneId: string) => {
|
||||
if (panesRef.current.length > 1) {
|
||||
closePane(paneId)
|
||||
return
|
||||
}
|
||||
const onlyTab = tabs.length === 1 ? tabs[0] : null
|
||||
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
|
||||
|
||||
const connectionId = connectionIdRef.current
|
||||
const paneShells = panesRef.current.flatMap((pane) => (pane.shellId ? [pane.shellId] : []))
|
||||
void (async () => {
|
||||
for (const paneShellId of paneShells) {
|
||||
const result = await shellKill(paneShellId).catch((error) => ({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (!result.success) {
|
||||
logger.warn('关闭分屏 Shell 失败', {
|
||||
shellId: closingShellId,
|
||||
logger.warn('关闭标签 Shell 失败', {
|
||||
shellId: paneShellId,
|
||||
error: result.error || 'unknown',
|
||||
})
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
await getConnectionStore().releaseConnection(connectionId, tabId)
|
||||
}
|
||||
closeTab(tabId)
|
||||
})()
|
||||
},
|
||||
[closePane, closeTab, connectionIdRef, tabId, tabs]
|
||||
)
|
||||
|
||||
const openTerminalContextMenu = useCallback(
|
||||
(paneId: string, position: { x: number; y: number }, hasSelection: boolean) => {
|
||||
setFocusedPaneId(paneId)
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
if (pane?.shellId && connectionIdRef.current) {
|
||||
shellIdRef.current = pane.shellId
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: pane.shellId,
|
||||
})
|
||||
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().removeShell(connectionId, pane.shellId)
|
||||
}
|
||||
}
|
||||
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
||||
setPanes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== paneId)
|
||||
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
||||
setFocusedPaneId(next[0].id)
|
||||
if (next[0].shellId) {
|
||||
shellIdRef.current = next[0].shellId
|
||||
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
|
||||
|
||||
const closePaneOrTab = useCallback((paneId: string) => {
|
||||
if (panesRef.current.length > 1) {
|
||||
closePane(paneId)
|
||||
return
|
||||
}
|
||||
const onlyTab = tabs.length === 1 ? tabs[0] : null
|
||||
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return
|
||||
|
||||
const connectionId = connectionIdRef.current
|
||||
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
||||
void (async () => {
|
||||
for (const paneShellId of paneShells) {
|
||||
const result = await shellKill(paneShellId).catch((error) => ({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (!result.success) {
|
||||
logger.warn('关闭标签 Shell 失败', {
|
||||
shellId: paneShellId,
|
||||
error: result.error || 'unknown',
|
||||
})
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
await getConnectionStore().releaseConnection(connectionId, tabId)
|
||||
}
|
||||
closeTab(tabId)
|
||||
})()
|
||||
}, [closePane, closeTab, connectionIdRef, tabId, tabs])
|
||||
|
||||
const openTerminalContextMenu = useCallback((
|
||||
paneId: string,
|
||||
position: { x: number; y: number },
|
||||
hasSelection: boolean
|
||||
) => {
|
||||
setFocusedPaneId(paneId)
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
if (pane?.shellId && connectionIdRef.current) {
|
||||
shellIdRef.current = pane.shellId
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: pane.shellId,
|
||||
})
|
||||
}
|
||||
setContextMenu({ paneId, position, hasSelection })
|
||||
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
|
||||
setContextMenu({ paneId, position, hasSelection })
|
||||
},
|
||||
[connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
||||
@@ -1183,7 +1214,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
if (!requirePro('ai')) return
|
||||
setAiPanelOpen(true)
|
||||
}}
|
||||
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
|
||||
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 text-accent-blue hover:border-accent-blue/40 hover:bg-accent-blue/15'}`}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> {t('toolbar.aiAssistant')}
|
||||
</button>
|
||||
@@ -1256,6 +1287,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
reason={pane.closed.reason}
|
||||
idleTimeout={pane.closed.idleTimeout}
|
||||
restarting={pane.closed.restarting}
|
||||
isLocal={isLocal}
|
||||
onReopen={() => void reopenPaneShell(pane.id)}
|
||||
/>
|
||||
)}
|
||||
@@ -1281,6 +1313,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
isLocal={isLocal}
|
||||
onReconnect={() => {
|
||||
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
|
||||
setPanes(Array.from({ length: count }, () => ({ id: newPaneId(), shellId: null })))
|
||||
@@ -1296,6 +1329,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
|
||||
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
|
||||
isLocal={false}
|
||||
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1315,7 +1349,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
|
||||
onSplitRight={() => setSplitDirection('horizontal')}
|
||||
onSplitDown={() => setSplitDirection('vertical')}
|
||||
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
|
||||
onToggleMaximize={() => setMaximizedPaneId((current) => (current ? null : contextMenu.paneId))}
|
||||
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
|
||||
onOpenSftp={
|
||||
!isLocal && currentTab?.sessionId
|
||||
|
||||
Reference in new issue
Block a user