feat(terminal): 实时终端改造为侧边栏面板,支持拖拽分割与自动开关
- 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列 - 使用 xterm.js + Grid 布局,支持三主题配色适配 - 上下拖拽分割条,动态调整终端/Git 面板比例 - 输入栏上方新增终端数量按钮,点击快捷打开 - 个人空间新增「自动打开终端面板」开关(外观与显示分类) - REST API /api/terminals 获取终端列表,多时机自动刷新 - 有终端自动打开面板,无终端自动关闭
This commit is contained in:
parent
788753f6fd
commit
2e54f2c343
24
package-lock.json
generated
24
package-lock.json
generated
@ -14,6 +14,8 @@
|
|||||||
"@tiptap/suggestion": "^3.23.6",
|
"@tiptap/suggestion": "^3.23.6",
|
||||||
"@tiptap/vue-3": "^3.23.6",
|
"@tiptap/vue-3": "^3.23.6",
|
||||||
"@types/html2canvas": "^0.5.35",
|
"@types/html2canvas": "^0.5.35",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
@ -33,7 +35,8 @@
|
|||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-stick-to-bottom": "^1.0.0"
|
"vue-stick-to-bottom": "^1.0.0",
|
||||||
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
@ -2609,6 +2612,18 @@
|
|||||||
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
|
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@ -7367,6 +7382,13 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
"@tiptap/suggestion": "^3.23.6",
|
"@tiptap/suggestion": "^3.23.6",
|
||||||
"@tiptap/vue-3": "^3.23.6",
|
"@tiptap/vue-3": "^3.23.6",
|
||||||
"@types/html2canvas": "^0.5.35",
|
"@types/html2canvas": "^0.5.35",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
@ -40,7 +42,8 @@
|
|||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-stick-to-bottom": "^1.0.0"
|
"vue-stick-to-bottom": "^1.0.0",
|
||||||
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
|
|||||||
@ -292,6 +292,7 @@
|
|||||||
:block-upload="policyUiBlocks.block_upload"
|
:block-upload="policyUiBlocks.block_upload"
|
||||||
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
|
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
|
||||||
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
|
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
|
||||||
|
:terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0"
|
||||||
:block-focus-panel="policyUiBlocks.block_focus_panel"
|
:block-focus-panel="policyUiBlocks.block_focus_panel"
|
||||||
:block-token-panel="policyUiBlocks.block_token_panel"
|
:block-token-panel="policyUiBlocks.block_token_panel"
|
||||||
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
|
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
|
||||||
@ -354,23 +355,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<div
|
<div
|
||||||
v-if="!isMobileViewport && gitChangesPanelOpen"
|
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
||||||
class="resize-handle resize-handle--git-changes"
|
class="resize-handle resize-handle--right-panels"
|
||||||
@mousedown="startResize('right', $event)"
|
@mousedown="startResize('right', $event)"
|
||||||
></div>
|
></div>
|
||||||
<transition name="git-changes-panel-slide">
|
<transition name="right-panels-slide">
|
||||||
<GitChangesPanel
|
<div
|
||||||
v-if="!isMobileViewport && gitChangesPanelOpen"
|
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
||||||
:width="Math.min(rightWidth || 420, 600)"
|
class="right-panels"
|
||||||
:loading="gitChangesLoading"
|
:style="rightPanelsStyle"
|
||||||
:error="gitChangesError"
|
>
|
||||||
:diff="gitChangesDiff"
|
<div class="right-panels__slot">
|
||||||
:fold-contexts="gitChangesFoldContexts"
|
<TerminalPanel
|
||||||
:host-mode="versioningHostMode"
|
:width="Math.min(rightWidth || 420, 600)"
|
||||||
@close="closeGitChangesPanel"
|
:workspace-id="currentHostWorkspaceId"
|
||||||
@expand-context="expandGitChangesContext"
|
@close="closeTerminalPanel"
|
||||||
@reset-context="resetGitChangesContext"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="terminalPanelOpen && gitChangesPanelOpen"
|
||||||
|
class="right-panels__split-handle"
|
||||||
|
@mousedown="startRightSplitResize"
|
||||||
|
></div>
|
||||||
|
<div class="right-panels__slot">
|
||||||
|
<GitChangesPanel
|
||||||
|
:width="Math.min(rightWidth || 420, 600)"
|
||||||
|
:loading="gitChangesLoading"
|
||||||
|
:error="gitChangesError"
|
||||||
|
:diff="gitChangesDiff"
|
||||||
|
:fold-contexts="gitChangesFoldContexts"
|
||||||
|
:host-mode="versioningHostMode"
|
||||||
|
@close="closeGitChangesPanel"
|
||||||
|
@expand-context="expandGitChangesContext"
|
||||||
|
@reset-context="resetGitChangesContext"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
<transition name="desktop-approval-fade">
|
<transition name="desktop-approval-fade">
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
|
|||||||
import LeftPanel from '../components/panels/LeftPanel.vue';
|
import LeftPanel from '../components/panels/LeftPanel.vue';
|
||||||
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
|
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
|
||||||
import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
|
import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
|
||||||
|
import TerminalPanel from '../components/panels/TerminalPanel.vue';
|
||||||
import TokenDrawer from '../components/token/TokenDrawer.vue';
|
import TokenDrawer from '../components/token/TokenDrawer.vue';
|
||||||
import QuickMenu from '../components/input/QuickMenu.vue';
|
import QuickMenu from '../components/input/QuickMenu.vue';
|
||||||
import InputComposer from '../components/input/InputComposer.vue';
|
import InputComposer from '../components/input/InputComposer.vue';
|
||||||
@ -50,6 +51,7 @@ export const appComponents = {
|
|||||||
LeftPanel,
|
LeftPanel,
|
||||||
ToolApprovalPanel,
|
ToolApprovalPanel,
|
||||||
GitChangesPanel,
|
GitChangesPanel,
|
||||||
|
TerminalPanel,
|
||||||
TokenDrawer,
|
TokenDrawer,
|
||||||
PersonalizationDrawer,
|
PersonalizationDrawer,
|
||||||
QuickMenu,
|
QuickMenu,
|
||||||
|
|||||||
@ -218,5 +218,21 @@ export const computed = {
|
|||||||
},
|
},
|
||||||
showScrollToBottomButton() {
|
showScrollToBottomButton() {
|
||||||
return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom;
|
return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom;
|
||||||
|
},
|
||||||
|
rightPanelsStyle() {
|
||||||
|
const w = Math.min(this.rightWidth || 420, 600);
|
||||||
|
const t = this.terminalPanelOpen;
|
||||||
|
const g = this.gitChangesPanelOpen;
|
||||||
|
const ratio = this.rightSplitRatio ?? 0.5;
|
||||||
|
const r = Math.max(0.15, Math.min(0.85, Number(ratio) || 0.5));
|
||||||
|
let rows: string;
|
||||||
|
if (t && g) rows = `${r}fr 2px ${1 - r}fr`;
|
||||||
|
else if (t) rows = '1fr 0px 0fr';
|
||||||
|
else if (g) rows = '0fr 0px 1fr';
|
||||||
|
else rows = '0fr 0px 0fr';
|
||||||
|
return {
|
||||||
|
width: w + 'px',
|
||||||
|
gridTemplateRows: rows
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export async function mounted() {
|
|||||||
this.refreshProjectGitSummary?.();
|
this.refreshProjectGitSummary?.();
|
||||||
this.startProjectGitSummaryIdleRefresh?.();
|
this.startProjectGitSummaryIdleRefresh?.();
|
||||||
this.startConnectionHeartbeat();
|
this.startConnectionHeartbeat();
|
||||||
|
this.fetchTerminalCount();
|
||||||
|
this.startTerminalCountIdleRefresh();
|
||||||
this.checkTutorialPrompt();
|
this.checkTutorialPrompt();
|
||||||
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
|
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
|
||||||
initialDataPromise
|
initialDataPromise
|
||||||
@ -120,6 +122,7 @@ export function beforeUnmount() {
|
|||||||
this.subAgentStopPolling();
|
this.subAgentStopPolling();
|
||||||
this.backgroundCommandStopPolling();
|
this.backgroundCommandStopPolling();
|
||||||
this.stopProjectGitSummaryIdleRefresh?.();
|
this.stopProjectGitSummaryIdleRefresh?.();
|
||||||
|
this.stopTerminalCountIdleRefresh?.();
|
||||||
this.resourceStopContainerStatsPolling();
|
this.resourceStopContainerStatsPolling();
|
||||||
this.resourceStopProjectStoragePolling();
|
this.resourceStopProjectStoragePolling();
|
||||||
this.resourceStopUsageQuotaPolling();
|
this.resourceStopUsageQuotaPolling();
|
||||||
|
|||||||
@ -339,6 +339,7 @@ export const conversationMethods = {
|
|||||||
this.skipConversationHistoryReload = true;
|
this.skipConversationHistoryReload = true;
|
||||||
this.currentConversationId = conversationId;
|
this.currentConversationId = conversationId;
|
||||||
this.refreshProjectGitSummary?.();
|
this.refreshProjectGitSummary?.();
|
||||||
|
this.fetchTerminalCount();
|
||||||
this.currentConversationTitle = result.title;
|
this.currentConversationTitle = result.title;
|
||||||
this.titleReady = true;
|
this.titleReady = true;
|
||||||
this.suppressTitleTyping = false;
|
this.suppressTitleTyping = false;
|
||||||
|
|||||||
@ -1308,6 +1308,7 @@ export const taskPollingMethods = {
|
|||||||
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']);
|
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']);
|
||||||
if (terminalStatuses.has(String(data.status))) {
|
if (terminalStatuses.has(String(data.status))) {
|
||||||
this.refreshProjectGitSummary?.();
|
this.refreshProjectGitSummary?.();
|
||||||
|
this.fetchTerminalCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.result !== undefined) {
|
if (data.result !== undefined) {
|
||||||
|
|||||||
@ -165,6 +165,20 @@ export const uiMethods = {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startTerminalCountIdleRefresh() {
|
||||||
|
if (this.terminalCountRefreshTimer) return;
|
||||||
|
this.terminalCountRefreshTimer = window.setInterval(() => {
|
||||||
|
if (!this.isConnected) return;
|
||||||
|
this.fetchTerminalCount();
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopTerminalCountIdleRefresh() {
|
||||||
|
if (!this.terminalCountRefreshTimer) return;
|
||||||
|
window.clearInterval(this.terminalCountRefreshTimer);
|
||||||
|
this.terminalCountRefreshTimer = null;
|
||||||
|
},
|
||||||
|
|
||||||
stopProjectGitSummaryIdleRefresh() {
|
stopProjectGitSummaryIdleRefresh() {
|
||||||
if (!this.projectGitSummaryRefreshTimer) {
|
if (!this.projectGitSummaryRefreshTimer) {
|
||||||
return;
|
return;
|
||||||
@ -1226,6 +1240,7 @@ export const uiMethods = {
|
|||||||
type: 'success'
|
type: 'success'
|
||||||
});
|
});
|
||||||
this.refreshProjectGitSummary?.();
|
this.refreshProjectGitSummary?.();
|
||||||
|
this.fetchTerminalCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.conversations = previousConversationList;
|
this.conversations = previousConversationList;
|
||||||
this.searchActive = previousSearchActive;
|
this.searchActive = previousSearchActive;
|
||||||
@ -2229,7 +2244,65 @@ export const uiMethods = {
|
|||||||
if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) {
|
if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.openRealtimeTerminal();
|
this.toggleTerminalPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTerminalPanel() {
|
||||||
|
this.terminalPanelOpen = !this.terminalPanelOpen;
|
||||||
|
if (this.terminalPanelOpen) {
|
||||||
|
this.subscribeTerminalEvents();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTerminalPanel() {
|
||||||
|
this.terminalPanelOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeTerminalEvents() {
|
||||||
|
const socket = this.socket;
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit('terminal_subscribe', { all: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// REST API 获取终端列表,用于输入栏按钮显示
|
||||||
|
async fetchTerminalCount() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/terminals');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.sessions) {
|
||||||
|
const sessions: Record<string, any> = {};
|
||||||
|
for (const s of data.sessions) {
|
||||||
|
const name = s.session_name || s.name || s.session || s.id;
|
||||||
|
if (name) sessions[name] = {};
|
||||||
|
}
|
||||||
|
this.terminalSessions = sessions;
|
||||||
|
// 自动打开/关闭终端面板
|
||||||
|
const personalizationStore = usePersonalizationStore();
|
||||||
|
const autoOpen = personalizationStore.form.auto_open_terminal_panel !== false;
|
||||||
|
const hasTerminals = Object.keys(sessions).length > 0;
|
||||||
|
if (autoOpen && hasTerminals && !this.terminalPanelOpen) {
|
||||||
|
this.toggleTerminalPanel();
|
||||||
|
} else if (autoOpen && !hasTerminals && this.terminalPanelOpen) {
|
||||||
|
this.closeTerminalPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTerminalSessions(sessions: Record<string, { working_dir?: string; shell?: string }>) {
|
||||||
|
this.terminalSessions = sessions;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTerminalActiveSession(session: string) {
|
||||||
|
this.terminalActiveSession = session;
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTerminalSession(name: string) {
|
||||||
|
this.terminalActiveSession = name;
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.emit('get_terminal_output', { session: name, lines: 0 });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleFocusPanelToggleClick() {
|
handleFocusPanelToggleClick() {
|
||||||
@ -2836,6 +2909,36 @@ export const uiMethods = {
|
|||||||
stopPanelResize(this);
|
stopPanelResize(this);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 右侧面板上下分割拖拽
|
||||||
|
startRightSplitResize(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const container = event.target.closest('.right-panels') as HTMLElement;
|
||||||
|
if (!container) return;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startHeight = container.offsetHeight;
|
||||||
|
const startRatio = this.rightSplitRatio ?? 0.5;
|
||||||
|
|
||||||
|
// 拖拽期间禁用 CSS transition,确保跟手
|
||||||
|
container.classList.add('right-panels--resizing');
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const dy = e.clientY - startY;
|
||||||
|
const newRatio = startRatio + dy / startHeight;
|
||||||
|
this.rightSplitRatio = Math.max(0.15, Math.min(0.85, newRatio));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
container.classList.remove('right-panels--resizing');
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
document.body.style.cursor = 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
getInputComposerRef() {
|
getInputComposerRef() {
|
||||||
return this.$refs.inputComposer || null;
|
return this.$refs.inputComposer || null;
|
||||||
},
|
},
|
||||||
@ -3395,11 +3498,7 @@ export const uiMethods = {
|
|||||||
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
|
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
openRealtimeTerminal() {
|
|
||||||
const { protocol, hostname, port } = window.location;
|
|
||||||
const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`;
|
|
||||||
window.open(target, '_blank');
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadInitialData() {
|
async loadInitialData() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function dataState() {
|
|||||||
runtimeQueueSyncLockUntil: 0,
|
runtimeQueueSyncLockUntil: 0,
|
||||||
projectGitSummary: null,
|
projectGitSummary: null,
|
||||||
projectGitSummaryRefreshTimer: null,
|
projectGitSummaryRefreshTimer: null,
|
||||||
|
terminalCountRefreshTimer: null,
|
||||||
projectGitSummaryRefreshing: false,
|
projectGitSummaryRefreshing: false,
|
||||||
gitChangesPanelOpen: false,
|
gitChangesPanelOpen: false,
|
||||||
gitChangesLoading: false,
|
gitChangesLoading: false,
|
||||||
@ -46,6 +47,15 @@ export function dataState() {
|
|||||||
gitChangesFileContexts: {},
|
gitChangesFileContexts: {},
|
||||||
gitChangesFoldContexts: {},
|
gitChangesFoldContexts: {},
|
||||||
gitChangesDiff: null,
|
gitChangesDiff: null,
|
||||||
|
// 实时终端面板
|
||||||
|
terminalPanelOpen: false,
|
||||||
|
terminalSessions: {} as Record<string, { working_dir?: string; shell?: string }>,
|
||||||
|
terminalActiveSession: '',
|
||||||
|
terminalSessionLogs: {} as Record<string, string>,
|
||||||
|
terminalHydrated: {} as Record<string, boolean>,
|
||||||
|
terminalCount: 0,
|
||||||
|
// 右侧面板上下分割比例(终端占比 0.15~0.85)
|
||||||
|
rightSplitRatio: 0.5,
|
||||||
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
|
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
|
||||||
composerReservedHeight: 80,
|
composerReservedHeight: 80,
|
||||||
// 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次
|
// 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次
|
||||||
|
|||||||
@ -132,6 +132,14 @@
|
|||||||
>
|
>
|
||||||
等待回答问题
|
等待回答问题
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="terminalCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="floating-project-status__terminal"
|
||||||
|
@click.stop="$emit('realtime-terminal')"
|
||||||
|
>
|
||||||
|
{{ terminalCount }} 个终端
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="projectGitSummaryForRender"
|
v-if="projectGitSummaryForRender"
|
||||||
type="button"
|
type="button"
|
||||||
@ -524,6 +532,7 @@ const props = defineProps<{
|
|||||||
goalRunning?: boolean;
|
goalRunning?: boolean;
|
||||||
goalProgress?: Record<string, any> | null;
|
goalProgress?: Record<string, any> | null;
|
||||||
hasPendingRuntimeGuidance?: boolean;
|
hasPendingRuntimeGuidance?: boolean;
|
||||||
|
terminalCount?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const inputStore = useInputStore();
|
const inputStore = useInputStore();
|
||||||
@ -2499,6 +2508,27 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--git-diff-del-text);
|
color: var(--git-diff-del-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-project-status__terminal {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-project-status__terminal:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 1px 2px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
.floating-project-status__submenu {
|
.floating-project-status__submenu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
|
|||||||
581
static/src/components/panels/TerminalPanel.vue
Normal file
581
static/src/components/panels/TerminalPanel.vue
Normal file
@ -0,0 +1,581 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="terminal-panel" :style="{ width: width + 'px' }">
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<header class="terminal-panel__header">
|
||||||
|
<div class="terminal-panel__tabs">
|
||||||
|
<div v-if="!sessionKeys.length" class="terminal-panel__empty-tab">
|
||||||
|
<span>等待终端会话...</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="name in sessionKeys"
|
||||||
|
:key="name"
|
||||||
|
type="button"
|
||||||
|
class="terminal-panel__tab"
|
||||||
|
:class="{ active: name === activeSession }"
|
||||||
|
:title="sessions[name]?.working_dir || name"
|
||||||
|
@click="switchToSession(name)"
|
||||||
|
>
|
||||||
|
<span class="terminal-panel__tab-name">{{ name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-panel__close"
|
||||||
|
aria-label="关闭终端面板"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 终端容器 -->
|
||||||
|
<div class="terminal-panel__body" ref="terminalContainer">
|
||||||
|
<div v-if="!sessionKeys.length" class="terminal-panel__idle">
|
||||||
|
<svg class="terminal-panel__idle-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2" y="3" width="20" height="16" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M6 7h12M6 10h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>暂无活动终端</span>
|
||||||
|
<span class="terminal-panel__idle-hint">AI 使用终端时将自动显示</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { io as createSocketClient } from 'socket.io-client';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
|
||||||
|
defineOptions({ name: 'TerminalPanel' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
width: number;
|
||||||
|
workspaceId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ---- 本地状态(自建 socket,不依赖父组件) ----
|
||||||
|
const sessions = ref<Record<string, { working_dir?: string; shell?: string }>>({});
|
||||||
|
const activeSession = ref('');
|
||||||
|
const sessionLogs = ref<Record<string, string>>({});
|
||||||
|
const sessionHydrated = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
let socket: ReturnType<typeof createSocketClient> | null = null;
|
||||||
|
|
||||||
|
const terminalContainer = ref<HTMLElement | null>(null);
|
||||||
|
let term: Terminal | null = null;
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
const _historyReady = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const sessionKeys = computed(() => Object.keys(sessions.value));
|
||||||
|
|
||||||
|
// ---- 主题适配 ----
|
||||||
|
function getCurrentTheme(): 'light' | 'dark' {
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme');
|
||||||
|
return theme === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightTerminalTheme() {
|
||||||
|
return {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#1f1f1f',
|
||||||
|
cursor: '#3d3929',
|
||||||
|
black: '#1f1f1f',
|
||||||
|
red: '#c04a2f',
|
||||||
|
green: '#4b8f60',
|
||||||
|
yellow: '#b48a2c',
|
||||||
|
blue: '#4a6ea9',
|
||||||
|
magenta: '#9b4d88',
|
||||||
|
cyan: '#2a8c8c',
|
||||||
|
white: '#f4f0ea',
|
||||||
|
brightBlack: '#6b6b6b',
|
||||||
|
brightRed: '#d94a3a',
|
||||||
|
brightGreen: '#5c9e6f',
|
||||||
|
brightYellow: '#c49a3c',
|
||||||
|
brightBlue: '#5a7eb9',
|
||||||
|
brightMagenta: '#ab5d98',
|
||||||
|
brightCyan: '#3a9c9c',
|
||||||
|
brightWhite: '#faf9f5'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkTerminalTheme() {
|
||||||
|
return {
|
||||||
|
background: '#181818',
|
||||||
|
foreground: '#d4d4d8',
|
||||||
|
cursor: '#d4d4d8',
|
||||||
|
black: '#181818',
|
||||||
|
red: '#f87171',
|
||||||
|
green: '#4ade80',
|
||||||
|
yellow: '#fbbf24',
|
||||||
|
blue: '#60a5fa',
|
||||||
|
magenta: '#c084fc',
|
||||||
|
cyan: '#22d3ee',
|
||||||
|
white: '#e4e4e7',
|
||||||
|
brightBlack: '#3f3f46',
|
||||||
|
brightRed: '#fb7185',
|
||||||
|
brightGreen: '#86efac',
|
||||||
|
brightYellow: '#fcd34d',
|
||||||
|
brightBlue: '#93c5fd',
|
||||||
|
brightMagenta: '#d8b4fe',
|
||||||
|
brightCyan: '#67e8f9',
|
||||||
|
brightWhite: '#fafafa'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTerminalTheme() {
|
||||||
|
if (!term) return;
|
||||||
|
const isDark = getCurrentTheme() === 'dark';
|
||||||
|
term.options.theme = isDark ? darkTerminalTheme() : lightTerminalTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 初始化 xterm ----
|
||||||
|
function initTerminal() {
|
||||||
|
if (!terminalContainer.value) return;
|
||||||
|
|
||||||
|
const isDark = getCurrentTheme() === 'dark';
|
||||||
|
const theme = isDark ? darkTerminalTheme() : lightTerminalTheme();
|
||||||
|
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Consolas", monospace',
|
||||||
|
theme,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: true,
|
||||||
|
allowTransparency: false,
|
||||||
|
disableStdin: true
|
||||||
|
});
|
||||||
|
|
||||||
|
term.open(terminalContainer.value);
|
||||||
|
|
||||||
|
// 持续自适应:手动 resize 而不是用 FitAddon(FitAddon 会操作 CSS 导致闪)
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!term || !terminalContainer.value) return;
|
||||||
|
const rect = terminalContainer.value.getBoundingClientRect();
|
||||||
|
if (rect.width < 40 || rect.height < 40) return;
|
||||||
|
// 估算字符宽高,计算行列数
|
||||||
|
const charW = term._core._renderService.dimensions.css.cell.width || 7.8;
|
||||||
|
const charH = term._core._renderService.dimensions.css.cell.height || 17;
|
||||||
|
const cols = Math.max(2, Math.floor((rect.width - 16) / charW));
|
||||||
|
const rows = Math.max(1, Math.floor((rect.height - 16) / charH));
|
||||||
|
if (cols !== term.cols || rows !== term.rows) {
|
||||||
|
term.resize(cols, rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(terminalContainer.value);
|
||||||
|
|
||||||
|
themeObserver = new MutationObserver(() => applyTerminalTheme());
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeTerminal() {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
term?.dispose();
|
||||||
|
term = null;
|
||||||
|
fitAddon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 终端渲染 ----
|
||||||
|
// 追加写入终端(不 clear,避免闪烁)
|
||||||
|
function appendToTerm(data: string) {
|
||||||
|
if (term && activeSession.value) {
|
||||||
|
term.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清屏 + 写入完整日志(仅切换会话 / 历史加载时用)
|
||||||
|
function renderSessionLog() {
|
||||||
|
if (!term || !activeSession.value) return;
|
||||||
|
const log = sessionLogs.value[activeSession.value] || '';
|
||||||
|
console.log('[TerminalPanel] renderSessionLog:', activeSession.value, 'len:', log.length, 'ready:', _historyReady.value[activeSession.value], 'hydrated:', sessionHydrated.value[activeSession.value]);
|
||||||
|
term.clear();
|
||||||
|
if (log) {
|
||||||
|
term.write(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToSession(name: string) {
|
||||||
|
console.log('[TerminalPanel] switchToSession:', name);
|
||||||
|
if (!name) return;
|
||||||
|
activeSession.value = name;
|
||||||
|
if (!sessionHydrated.value[name]) {
|
||||||
|
_historyReady.value = { ..._historyReady.value, [name]: false };
|
||||||
|
if (socket?.connected) {
|
||||||
|
console.log('[TerminalPanel] requesting history for:', name);
|
||||||
|
socket.emit('get_terminal_output', { session: name, lines: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// renderSessionLog 由 watch(activeSession) 统一触发,此处不重复调用
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- watchers ----
|
||||||
|
watch(activeSession, (val) => {
|
||||||
|
console.log('[TerminalPanel] activeSession:', val);
|
||||||
|
if (term) renderSessionLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(sessionKeys, (keys) => {
|
||||||
|
console.log('[TERMINAL_DEBUG] sessionKeys:', keys);
|
||||||
|
if (keys.length > 0 && term && !activeSession.value) {
|
||||||
|
switchToSession(keys[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工作区切换时重连
|
||||||
|
watch(() => props.workspaceId, (newId, oldId) => {
|
||||||
|
if (newId && oldId && newId !== oldId) {
|
||||||
|
console.log('[TerminalPanel] workspace changed:', oldId, '->', newId);
|
||||||
|
// 断开旧连接,清空状态,重新连接
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
sessions.value = {};
|
||||||
|
activeSession.value = '';
|
||||||
|
sessionLogs.value = {};
|
||||||
|
sessionHydrated.value = {};
|
||||||
|
_historyReady.value = {};
|
||||||
|
if (term) term.clear();
|
||||||
|
initSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- socket 初始化 ----
|
||||||
|
async function initSocket() {
|
||||||
|
console.log('[TerminalPanel] initSocket start');
|
||||||
|
|
||||||
|
socket = createSocketClient('/', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
autoConnect: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignToken = async (): Promise<boolean> => {
|
||||||
|
const w = window as any;
|
||||||
|
if (typeof w.requestSocketToken !== 'function') {
|
||||||
|
console.warn('[TerminalPanel] requestSocketToken 不可用');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = await w.requestSocketToken();
|
||||||
|
(socket as any).auth = { socket_token: token };
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TerminalPanel] 获取 token 失败:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.io.on('reconnect_attempt', () => assignToken());
|
||||||
|
|
||||||
|
const ready = await assignToken();
|
||||||
|
if (!ready) {
|
||||||
|
console.error('[TerminalPanel] token 获取失败,放弃 socket');
|
||||||
|
socket = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[TerminalPanel] socket connected');
|
||||||
|
socket!.emit('terminal_subscribe', { all: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('[TerminalPanel] socket disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_subscribed', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_subscribed:', data);
|
||||||
|
if (data?.terminals) {
|
||||||
|
const map: Record<string, { working_dir?: string; shell?: string }> = {};
|
||||||
|
for (const t of data.terminals) {
|
||||||
|
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
|
||||||
|
}
|
||||||
|
sessions.value = map;
|
||||||
|
if (data.terminals.length > 0 && !activeSession.value) {
|
||||||
|
switchToSession(data.terminals[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_started', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_started:', data);
|
||||||
|
sessions.value = {
|
||||||
|
...sessions.value,
|
||||||
|
[data.session]: { working_dir: data.working_dir, shell: 'bash' }
|
||||||
|
};
|
||||||
|
if (!activeSession.value) switchToSession(data.session);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_list_update', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_list_update:', data);
|
||||||
|
if (data?.terminals) {
|
||||||
|
const map: Record<string, { working_dir?: string; shell?: string }> = {};
|
||||||
|
for (const t of data.terminals) {
|
||||||
|
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
|
||||||
|
}
|
||||||
|
sessions.value = map;
|
||||||
|
if (data.active && !activeSession.value) switchToSession(data.active);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_output', (data: any) => {
|
||||||
|
const s = data.session;
|
||||||
|
if (!s) return;
|
||||||
|
const delta = (data.data || '') as string;
|
||||||
|
if (s === activeSession.value && term && _historyReady.value[s]) {
|
||||||
|
console.log('[TerminalPanel] terminal_output WRITE:', s, 'len:', delta.length);
|
||||||
|
term.write(delta);
|
||||||
|
// 同步更新 sessionLogs,确保切换会话后新输出不丢失
|
||||||
|
sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta;
|
||||||
|
} else {
|
||||||
|
console.log('[TerminalPanel] terminal_output SKIP:', s, 'len:', delta.length, 'isActive:', s === activeSession.value, 'ready:', _historyReady.value[s]);
|
||||||
|
}
|
||||||
|
if (!activeSession.value) switchToSession(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_input', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_input:', data.session);
|
||||||
|
// terminal_output 已包含完整的 prompt + 命令回显,此处不重复写入
|
||||||
|
// 仅用于新终端自动切换
|
||||||
|
if (!activeSession.value && data.session) switchToSession(data.session);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_closed', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_closed:', data);
|
||||||
|
const updated = { ...sessions.value };
|
||||||
|
delete updated[data.session];
|
||||||
|
sessions.value = updated;
|
||||||
|
const remaining = Object.keys(updated);
|
||||||
|
if (activeSession.value === data.session) {
|
||||||
|
activeSession.value = remaining.length > 0 ? remaining[0] : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_reset', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_reset:', data);
|
||||||
|
const target = data.session || activeSession.value;
|
||||||
|
if (target) {
|
||||||
|
sessionLogs.value = { ...sessionLogs.value, [target]: '' };
|
||||||
|
sessionHydrated.value = { ...sessionHydrated.value, [target]: true };
|
||||||
|
if (target === activeSession.value) renderSessionLog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('terminal_switched', (data: any) => {
|
||||||
|
console.log('[TerminalPanel] terminal_switched:', data);
|
||||||
|
if (data.current) switchToSession(data.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleHistory = (data: any) => {
|
||||||
|
console.log('[TerminalPanel] handleHistory FIRED, event session:', data.session, 'data keys:', Object.keys(data).join(','));
|
||||||
|
const s = data.session || data.active;
|
||||||
|
if (!s) return;
|
||||||
|
const raw = data.output || data.data || '';
|
||||||
|
let payload = '';
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
payload = raw;
|
||||||
|
} else if (Array.isArray(raw)) {
|
||||||
|
payload = raw.join('\n');
|
||||||
|
}
|
||||||
|
console.log('[TerminalPanel] handleHistory:', s, 'rawLen:', raw.length, 'payloadLen:', payload.length, 'isActive:', s === activeSession.value);
|
||||||
|
if (payload) {
|
||||||
|
payload = payload.replace(/\x1b\[1;32m➜.*?\x1b\[0m\n?/g, '');
|
||||||
|
sessionLogs.value[s] = payload;
|
||||||
|
sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
|
||||||
|
_historyReady.value = { ..._historyReady.value, [s]: true };
|
||||||
|
console.log('[TerminalPanel] handleHistory SAVED:', s, 'finalLen:', payload.length, 'calling render:', s === activeSession.value);
|
||||||
|
if (s === activeSession.value) {
|
||||||
|
renderSessionLog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sessionHydrated.value[s]) {
|
||||||
|
sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('terminal_output_history', handleHistory);
|
||||||
|
socket.on('terminal_history', handleHistory);
|
||||||
|
|
||||||
|
socket.connect();
|
||||||
|
console.log('[TerminalPanel] socket connecting...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 生命周期 ----
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
initTerminal();
|
||||||
|
initSocket();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposeTerminal();
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-rail);
|
||||||
|
border-left: 1px solid var(--border-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
background: var(--surface-rail);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__empty-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tab:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tab.active {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .terminal-panel__tab:hover,
|
||||||
|
[data-theme='dark'] .terminal-panel__tab.active {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__tab-name {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__close:hover {
|
||||||
|
background: var(--theme-tab-active);
|
||||||
|
color: var(--claude-text);
|
||||||
|
box-shadow: 0 1px 2px var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__idle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__idle-svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__idle-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__body :deep(.xterm) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__body :deep(.xterm-screen) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__body :deep(.xterm-viewport) {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panel__body :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -702,6 +702,28 @@
|
|||||||
class="fancy-path"
|
class="fancy-path"
|
||||||
></path></svg></span
|
></path></svg></span
|
||||||
></label>
|
></label>
|
||||||
|
<label class="settings-toggle-row"
|
||||||
|
><span class="settings-row-copy"
|
||||||
|
><span class="settings-row-title">自动打开终端面板</span
|
||||||
|
><span class="settings-row-desc"
|
||||||
|
>创建终端时自动打开侧边栏终端面板</span
|
||||||
|
></span
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.auto_open_terminal_panel"
|
||||||
|
@change="
|
||||||
|
personalization.updateField({
|
||||||
|
key: 'auto_open_terminal_panel',
|
||||||
|
value: $event.target.checked
|
||||||
|
})
|
||||||
|
" /><span class="fancy-check" aria-hidden="true"
|
||||||
|
><svg viewBox="0 0 64 64">
|
||||||
|
<path
|
||||||
|
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||||
|
pathLength="575.0541381835938"
|
||||||
|
class="fancy-path"
|
||||||
|
></path></svg></span
|
||||||
|
></label>
|
||||||
<div class="settings-select-row">
|
<div class="settings-select-row">
|
||||||
<span class="settings-row-copy"
|
<span class="settings-row-copy"
|
||||||
><span class="settings-row-title">堆叠块显示模式</span
|
><span class="settings-row-title">堆叠块显示模式</span
|
||||||
|
|||||||
@ -624,6 +624,9 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
ctx.isConnected = true;
|
ctx.isConnected = true;
|
||||||
socketLog('WebSocket已连接');
|
socketLog('WebSocket已连接');
|
||||||
|
|
||||||
|
// 自动订阅终端事件,确保终端面板数据始终就绪
|
||||||
|
ctx.socket.emit('terminal_subscribe', { all: true });
|
||||||
|
|
||||||
// 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画
|
// 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画
|
||||||
if (!isReconnect && (historyLoadingSame || historyReady)) {
|
if (!isReconnect && (historyLoadingSame || historyReady)) {
|
||||||
socketLog('初次连接已存在历史,跳过重复重置与加载');
|
socketLog('初次连接已存在历史,跳过重复重置与加载');
|
||||||
@ -1763,6 +1766,119 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
ctx.addSystemMessage(`命令失败: ${data.message}`);
|
ctx.addSystemMessage(`命令失败: ${data.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === 实时终端事件 ===
|
||||||
|
ctx.socket.on('terminal_subscribed', (data) => {
|
||||||
|
console.log('[Socket] terminal_subscribed:', data);
|
||||||
|
if (data?.terminals) {
|
||||||
|
const sessions: Record<string, { working_dir?: string; shell?: string }> = {};
|
||||||
|
for (const t of data.terminals) {
|
||||||
|
sessions[t.name] = {
|
||||||
|
working_dir: t.working_dir,
|
||||||
|
shell: 'bash'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.setTerminalSessions(sessions);
|
||||||
|
if (data.terminals.length > 0 && !ctx.terminalActiveSession) {
|
||||||
|
ctx.setTerminalActiveSession(data.terminals[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_started', (data) => {
|
||||||
|
console.log('[Socket] terminal_started:', data);
|
||||||
|
if (data?.session) {
|
||||||
|
const sessions = { ...ctx.terminalSessions };
|
||||||
|
sessions[data.session] = {
|
||||||
|
working_dir: data.working_dir,
|
||||||
|
shell: 'bash'
|
||||||
|
};
|
||||||
|
ctx.setTerminalSessions(sessions);
|
||||||
|
if (!ctx.terminalActiveSession) {
|
||||||
|
ctx.setTerminalActiveSession(data.session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_list_update', (data) => {
|
||||||
|
if (data?.terminals) {
|
||||||
|
const sessions: Record<string, { working_dir?: string; shell?: string }> = {};
|
||||||
|
for (const t of data.terminals) {
|
||||||
|
sessions[t.name] = {
|
||||||
|
working_dir: t.working_dir,
|
||||||
|
shell: 'bash'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.setTerminalSessions(sessions);
|
||||||
|
if (!ctx.terminalActiveSession && data.terminals.length > 0) {
|
||||||
|
ctx.setTerminalActiveSession(data.terminals[0].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_closed', (data) => {
|
||||||
|
if (data?.session) {
|
||||||
|
const sessions = { ...ctx.terminalSessions };
|
||||||
|
delete sessions[data.session];
|
||||||
|
ctx.setTerminalSessions(sessions);
|
||||||
|
if (ctx.terminalActiveSession === data.session) {
|
||||||
|
const names = Object.keys(sessions);
|
||||||
|
ctx.setTerminalActiveSession(names.length > 0 ? names[0] : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_reset', (data) => {
|
||||||
|
if (data?.session) {
|
||||||
|
const logs = { ...ctx.terminalSessionLogs };
|
||||||
|
logs[data.session] = '';
|
||||||
|
ctx.terminalSessionLogs = logs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_output_history', (data) => {
|
||||||
|
if (data?.session && data?.output) {
|
||||||
|
const logs = { ...ctx.terminalSessionLogs };
|
||||||
|
logs[data.session] = (logs[data.session] || '') + data.output;
|
||||||
|
ctx.terminalSessionLogs = logs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_history', (data) => {
|
||||||
|
if (data?.session && data?.output) {
|
||||||
|
const logs = { ...ctx.terminalSessionLogs };
|
||||||
|
// 历史数据前置(完整替换),因为这是完整的历史记录
|
||||||
|
logs[data.session] = data.output;
|
||||||
|
ctx.terminalSessionLogs = logs;
|
||||||
|
// 标记已水合
|
||||||
|
const hydrated = { ...ctx.terminalHydrated };
|
||||||
|
hydrated[data.session] = true;
|
||||||
|
ctx.terminalHydrated = hydrated;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_output', (data) => {
|
||||||
|
if (data?.session && data?.data) {
|
||||||
|
const logs = { ...ctx.terminalSessionLogs };
|
||||||
|
logs[data.session] = (logs[data.session] || '') + data.data;
|
||||||
|
if (logs[data.session] && logs[data.session].length > 400000) {
|
||||||
|
logs[data.session] = logs[data.session].slice(-400000);
|
||||||
|
}
|
||||||
|
ctx.terminalSessionLogs = logs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.socket.on('terminal_input', (data) => {
|
||||||
|
if (data?.session && data?.data) {
|
||||||
|
const formatted = `\x1b[1;32m➤ ${data.data}\x1b[0m`;
|
||||||
|
const logs = { ...ctx.terminalSessionLogs };
|
||||||
|
logs[data.session] = (logs[data.session] || '') + formatted;
|
||||||
|
if (logs[data.session] && logs[data.session].length > 400000) {
|
||||||
|
logs[data.session] = logs[data.session].slice(-400000);
|
||||||
|
}
|
||||||
|
ctx.terminalSessionLogs = logs;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Socket初始化失败:', error);
|
console.error('Socket初始化失败:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ interface PersonalForm {
|
|||||||
enhanced_tool_display: boolean;
|
enhanced_tool_display: boolean;
|
||||||
compact_message_display: CompactMessageDisplay;
|
compact_message_display: CompactMessageDisplay;
|
||||||
show_git_status_bar: boolean;
|
show_git_status_bar: boolean;
|
||||||
|
auto_open_terminal_panel: boolean;
|
||||||
stacked_hide_borders: boolean;
|
stacked_hide_borders: boolean;
|
||||||
enhanced_tool_display_categories: string[];
|
enhanced_tool_display_categories: string[];
|
||||||
enabled_skills: string[];
|
enabled_skills: string[];
|
||||||
@ -124,6 +125,7 @@ const defaultForm = (): PersonalForm => ({
|
|||||||
enhanced_tool_display: true,
|
enhanced_tool_display: true,
|
||||||
compact_message_display: 'full',
|
compact_message_display: 'full',
|
||||||
show_git_status_bar: true,
|
show_git_status_bar: true,
|
||||||
|
auto_open_terminal_panel: true,
|
||||||
stacked_hide_borders: false,
|
stacked_hide_borders: false,
|
||||||
enhanced_tool_display_categories: [],
|
enhanced_tool_display_categories: [],
|
||||||
enabled_skills: [],
|
enabled_skills: [],
|
||||||
@ -315,6 +317,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
|||||||
enhanced_tool_display: data.enhanced_tool_display !== false,
|
enhanced_tool_display: data.enhanced_tool_display !== false,
|
||||||
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full',
|
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full',
|
||||||
show_git_status_bar: data.show_git_status_bar !== false,
|
show_git_status_bar: data.show_git_status_bar !== false,
|
||||||
|
auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
|
||||||
stacked_hide_borders: !!data.stacked_hide_borders,
|
stacked_hide_borders: !!data.stacked_hide_borders,
|
||||||
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
|
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
|
||||||
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')
|
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')
|
||||||
|
|||||||
@ -75,6 +75,7 @@ interface UiState {
|
|||||||
leftWidth: number;
|
leftWidth: number;
|
||||||
rightWidth: number;
|
rightWidth: number;
|
||||||
rightCollapsed: boolean;
|
rightCollapsed: boolean;
|
||||||
|
rightSplitRatio: number;
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
resizingPanel: ResizingPanel;
|
resizingPanel: ResizingPanel;
|
||||||
minPanelWidth: number;
|
minPanelWidth: number;
|
||||||
@ -101,6 +102,7 @@ export const useUiStore = defineStore('ui', {
|
|||||||
leftWidth: 350,
|
leftWidth: 350,
|
||||||
rightWidth: 420,
|
rightWidth: 420,
|
||||||
rightCollapsed: true,
|
rightCollapsed: true,
|
||||||
|
rightSplitRatio: 0.5,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
resizingPanel: null,
|
resizingPanel: null,
|
||||||
minPanelWidth: 350,
|
minPanelWidth: 350,
|
||||||
@ -161,6 +163,9 @@ export const useUiStore = defineStore('ui', {
|
|||||||
setRightCollapsed(collapsed: boolean) {
|
setRightCollapsed(collapsed: boolean) {
|
||||||
this.rightCollapsed = collapsed;
|
this.rightCollapsed = collapsed;
|
||||||
},
|
},
|
||||||
|
setRightSplitRatio(ratio: number) {
|
||||||
|
this.rightSplitRatio = Math.max(0.15, Math.min(0.85, ratio));
|
||||||
|
},
|
||||||
setResizing(state: boolean, panel: ResizingPanel = null) {
|
setResizing(state: boolean, panel: ResizingPanel = null) {
|
||||||
this.isResizing = state;
|
this.isResizing = state;
|
||||||
this.resizingPanel = state ? panel : null;
|
this.resizingPanel = state ? panel : null;
|
||||||
|
|||||||
@ -480,23 +480,78 @@ body[data-theme='dark'] .git-change-file__open-icon {
|
|||||||
color: var(--state-danger);
|
color: var(--state-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-changes-panel-slide-enter-active,
|
/* === 外层容器:width 滑入/滑出(长写属性,不覆盖 grid 过渡) === */
|
||||||
.git-changes-panel-slide-leave-active {
|
|
||||||
transition:
|
/* 基础过渡:只用于 grid-template-rows */
|
||||||
width 0.22s ease,
|
.right-panels {
|
||||||
min-width 0.22s ease,
|
transition-property: grid-template-rows;
|
||||||
opacity 0.18s ease,
|
transition-duration: 0.28s;
|
||||||
transform 0.22s ease;
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-changes-panel-slide-enter-from,
|
/* enter/leave 时追加 width/opacity/transform 过渡 */
|
||||||
.git-changes-panel-slide-leave-to {
|
.right-panels-slide-enter-active,
|
||||||
|
.right-panels-slide-leave-active {
|
||||||
|
transition-property: width, min-width, opacity, transform;
|
||||||
|
transition-duration: 0.22s, 0.22s, 0.18s, 0.22s;
|
||||||
|
transition-timing-function: ease, ease, ease, ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panels-slide-enter-from,
|
||||||
|
.right-panels-slide-leave-to {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
min-width: 0;
|
min-width: 0 !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* leave 时强制收缩 grid,配合过渡 */
|
||||||
|
.right-panels-slide-leave-to {
|
||||||
|
grid-template-rows: 0fr 0px 0fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 右侧面板容器(Grid 布局,grid-template-rows 过渡) === */
|
||||||
|
.right-panels {
|
||||||
|
display: grid;
|
||||||
|
min-width: 340px;
|
||||||
|
max-width: min(600px, 52vw);
|
||||||
|
height: var(--app-viewport);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panels__slot {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强制 Git 面板始终在第 3 行,即使 split-handle 不渲染 */
|
||||||
|
.right-panels__slot:last-child {
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panels .git-changes-panel,
|
||||||
|
.right-panels .terminal-panel {
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上下分割拖拽条:纯交互层,分隔线由 Git 面板 border-top 提供 */
|
||||||
|
.right-panels__split-handle {
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽期间禁用所有过渡,确保跟手 */
|
||||||
|
.right-panels--resizing {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git 面板上边线 */
|
||||||
|
.right-panels .git-changes-panel {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
.no-files {
|
.no-files {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--claude-text-secondary);
|
color: var(--claude-text-secondary);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user