mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-04 19:08:40 +02:00
refactor: move git panel to tabbed bottom panel alongside terminal
Replace the narrow 256px sidebar git section with a shared tabbed bottom panel that hosts both Terminal and Git as peer tabs. This gives git operations full horizontal width for file paths, diff previews, branch lists, and commit history. - Create BottomPanel with shared resize handle, tab bar, and content switching (terminal stays mounted via CSS visibility when inactive) - Simplify TerminalPanel by extracting resize/close to BottomPanel - Add InlineGitPill toggle in composer footer next to Terminal pill - Wire tab switching: clicking active tab closes panel, clicking inactive tab switches to it - Remove GitPanel from ActivityPanel sidebar - Update ARCHITECTURE.md to describe tabbed bottom panel layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+59
-23
@@ -13,7 +13,9 @@ import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
|
||||
import { Sidebar } from '@renderer/components/Sidebar';
|
||||
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
|
||||
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { TerminalPanel } from '@renderer/components/TerminalPanel';
|
||||
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
@@ -129,12 +131,14 @@ export default function App() {
|
||||
// Commit composer state
|
||||
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
|
||||
|
||||
// Terminal state
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
|
||||
// Bottom panel state (terminal + git)
|
||||
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
|
||||
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
|
||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(
|
||||
() => workspace?.settings.terminalHeight ?? DEFAULT_BOTTOM_HEIGHT,
|
||||
);
|
||||
const [terminalRunning, setTerminalRunning] = useState(false);
|
||||
const [gitDirty, setGitDirty] = useState(false);
|
||||
|
||||
// Load workspace on mount
|
||||
useEffect(() => {
|
||||
@@ -315,7 +319,7 @@ export default function App() {
|
||||
// ── Ctrl+` — Toggle terminal ──
|
||||
if (e.ctrlKey && e.key === '`') {
|
||||
e.preventDefault();
|
||||
setTerminalOpen((prev) => !prev);
|
||||
handleTerminalToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,26 +466,38 @@ export default function App() {
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Sync terminalHeight from workspace settings when workspace loads
|
||||
// Sync bottom panel height from workspace settings when workspace loads
|
||||
useEffect(() => {
|
||||
if (workspace?.settings.terminalHeight) {
|
||||
setTerminalHeight(workspace.settings.terminalHeight);
|
||||
setBottomPanelHeight(workspace.settings.terminalHeight);
|
||||
}
|
||||
}, [workspace?.settings.terminalHeight]);
|
||||
|
||||
const handleTerminalHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
|
||||
setTerminalHeight(clamped);
|
||||
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
|
||||
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
|
||||
setBottomPanelHeight(clamped);
|
||||
void api.setTerminalHeight({ height: clamped });
|
||||
}, [api]);
|
||||
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
const handleBottomPanelClose = useCallback(() => {
|
||||
setBottomPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTerminalToggle = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'terminal') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('terminal');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const handleGitToggle = useCallback(() => {
|
||||
setBottomPanelOpen((prev) => {
|
||||
if (prev && bottomPanelTab === 'git') return false;
|
||||
return true;
|
||||
});
|
||||
setBottomPanelTab('git');
|
||||
}, [bottomPanelTab]);
|
||||
|
||||
const jumpToMessage = useCallback((messageId: string) => {
|
||||
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
|
||||
@@ -637,14 +653,17 @@ export default function App() {
|
||||
availableModels={availableModels}
|
||||
mcpProbingServerIds={workspace.mcpProbingServerIds}
|
||||
onTerminalToggle={handleTerminalToggle}
|
||||
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
|
||||
pattern={patternForSession}
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={terminalOpen}
|
||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||
terminalRunning={terminalRunning}
|
||||
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
|
||||
gitDirty={gitDirty}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
/>
|
||||
);
|
||||
@@ -740,13 +759,30 @@ export default function App() {
|
||||
content={content}
|
||||
detailPanel={detailPanel}
|
||||
overlay={overlay}
|
||||
terminalPanel={
|
||||
terminalOpen ? (
|
||||
<TerminalPanel
|
||||
height={terminalHeight}
|
||||
onHeightChange={handleTerminalHeightChange}
|
||||
onClose={handleTerminalClose}
|
||||
onMinimize={handleTerminalClose}
|
||||
bottomPanel={
|
||||
bottomPanelOpen ? (
|
||||
<BottomPanel
|
||||
activeTab={bottomPanelTab}
|
||||
gitContent={
|
||||
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
|
||||
<GitPanel
|
||||
onDirtyChange={setGitDirty}
|
||||
projectId={selectedSession.projectId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
|
||||
Git is not available for scratchpad sessions
|
||||
</div>
|
||||
)
|
||||
}
|
||||
gitDirty={gitDirty}
|
||||
height={bottomPanelHeight}
|
||||
onClose={handleBottomPanelClose}
|
||||
onHeightChange={handleBottomPanelHeightChange}
|
||||
onTabChange={setBottomPanelTab}
|
||||
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
|
||||
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
|
||||
terminalRunning={terminalRunning}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, GitBranch as GitBranchIcon, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { GitPanel } from '@renderer/components/GitPanel';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
@@ -395,18 +393,6 @@ export function ActivityPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Git panel section ────────────────────────────── */}
|
||||
{!isScratchpadProject(session.projectId) && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<GitBranchIcon className="size-3" />
|
||||
<span>Git</span>
|
||||
</SectionHeader>
|
||||
|
||||
<GitPanel projectId={session.projectId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,11 @@ interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
detailPanel?: ReactNode;
|
||||
terminalPanel?: ReactNode;
|
||||
bottomPanel?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
|
||||
export function AppShell({ sidebar, content, detailPanel, bottomPanel, overlay }: AppShellProps) {
|
||||
return (
|
||||
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-[var(--color-text-primary)]">
|
||||
{/* Full-width drag region matching the title bar overlay height */}
|
||||
@@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
{/* Main content + terminal */}
|
||||
{/* Main content + bottom panel */}
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Ambient glow behind active content area */}
|
||||
<div
|
||||
@@ -27,7 +27,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
|
||||
style={{ background: 'var(--gradient-glow)' }}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1">{content}</div>
|
||||
{terminalPanel}
|
||||
{bottomPanel}
|
||||
</main>
|
||||
|
||||
{/* Detail panel */}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from 'react';
|
||||
import { GitBranch, Minus, TerminalSquare, X } from 'lucide-react';
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────── */
|
||||
|
||||
const MIN_HEIGHT = 120;
|
||||
const MAX_HEIGHT_FRACTION = 0.7;
|
||||
const DEFAULT_HEIGHT = 280;
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────── */
|
||||
|
||||
export type BottomPanelTab = 'terminal' | 'git';
|
||||
|
||||
/* ── BottomPanel ──────────────────────────────────────────── */
|
||||
|
||||
interface BottomPanelProps {
|
||||
activeTab: BottomPanelTab;
|
||||
onTabChange: (tab: BottomPanelTab) => void;
|
||||
onClose: () => void;
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
terminalContent: ReactNode;
|
||||
gitContent: ReactNode;
|
||||
showGitTab: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitDirty?: boolean;
|
||||
}
|
||||
|
||||
export function BottomPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
height,
|
||||
onHeightChange,
|
||||
terminalContent,
|
||||
gitContent,
|
||||
showGitTab,
|
||||
terminalRunning,
|
||||
gitDirty,
|
||||
}: BottomPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize panel"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex h-8 shrink-0 items-center border-b border-[var(--color-border)] px-1">
|
||||
{/* Terminal tab */}
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'terminal'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('terminal')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'terminal'}
|
||||
>
|
||||
{terminalRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
|
||||
<TerminalSquare className="size-3" />
|
||||
Terminal
|
||||
</button>
|
||||
|
||||
{/* Git tab */}
|
||||
{showGitTab && (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onTabChange('git')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'git'}
|
||||
>
|
||||
{gitDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
Git
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Minimize */}
|
||||
<button
|
||||
aria-label="Minimize panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
aria-label="Close panel"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content — both always mounted, only active one visible */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className={`absolute inset-0 flex flex-col ${activeTab === 'terminal' ? '' : 'invisible'}`}>
|
||||
{terminalContent}
|
||||
</div>
|
||||
{showGitTab && (
|
||||
<div className={`absolute inset-0 flex flex-col overflow-y-auto ${activeTab === 'git' ? '' : 'hidden'}`}>
|
||||
{gitContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DEFAULT_HEIGHT, MIN_HEIGHT };
|
||||
@@ -9,7 +9,7 @@ import { MessageEditComposer } from '@renderer/components/chat/MessageEditCompos
|
||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
|
||||
@@ -53,6 +53,8 @@ interface ChatPaneProps {
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
gitPanelOpen?: boolean;
|
||||
gitDirty?: boolean;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
@@ -62,6 +64,7 @@ interface ChatPaneProps {
|
||||
onDismissMcpAuth?: () => void;
|
||||
onAuthenticateMcp?: () => void;
|
||||
onTerminalToggle?: () => void;
|
||||
onGitToggle?: () => void;
|
||||
onUpdateSessionModelConfig?: (config: {
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
@@ -87,6 +90,8 @@ export function ChatPane({
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
gitPanelOpen,
|
||||
gitDirty,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
@@ -96,6 +101,7 @@ export function ChatPane({
|
||||
onDismissMcpAuth,
|
||||
onAuthenticateMcp,
|
||||
onTerminalToggle,
|
||||
onGitToggle,
|
||||
onUpdateSessionModelConfig,
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
@@ -751,6 +757,13 @@ export function ChatPane({
|
||||
onToggle={onTerminalToggle}
|
||||
/>
|
||||
)}
|
||||
{onGitToggle && !isScratchpad && (
|
||||
<InlineGitPill
|
||||
isDirty={!!gitDirty}
|
||||
isOpen={!!gitPanelOpen}
|
||||
onToggle={onGitToggle}
|
||||
/>
|
||||
)}
|
||||
{!isScratchpad && promptFiles.length > 0 && (
|
||||
<InlinePromptPill
|
||||
disabled={isComposerDisabled}
|
||||
|
||||
@@ -310,9 +310,10 @@ function CreateBranchForm({
|
||||
|
||||
interface GitPanelProps {
|
||||
projectId: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId }: GitPanelProps) {
|
||||
export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) {
|
||||
const api = getElectronApi();
|
||||
|
||||
const [details, setDetails] = useState<ProjectGitDetails>();
|
||||
@@ -334,6 +335,7 @@ export function GitPanel({ projectId }: GitPanelProps) {
|
||||
const result = await api.getProjectGitDetails({ projectId });
|
||||
setDetails(result);
|
||||
setOperationError(undefined);
|
||||
onDirtyChange?.(result.context.isDirty ?? false);
|
||||
} catch (error) {
|
||||
setOperationError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RotateCcw, Minus, X } from 'lucide-react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
@@ -44,17 +44,11 @@ const DEFAULT_HEIGHT = 280;
|
||||
/* ── TerminalPanel ────────────────────────────────────────── */
|
||||
|
||||
interface TerminalPanelProps {
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
height,
|
||||
onHeightChange,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onRunningChange,
|
||||
}: TerminalPanelProps) {
|
||||
const api = getElectronApi();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -62,8 +56,6 @@ export function TerminalPanel({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
|
||||
|
||||
// Create or recover terminal on mount
|
||||
useEffect(() => {
|
||||
@@ -74,11 +66,13 @@ export function TerminalPanel({
|
||||
if (existing) {
|
||||
setSnapshot(existing);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
} else {
|
||||
void api.createTerminal().then((created) => {
|
||||
if (disposed) return;
|
||||
setSnapshot(created);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -135,6 +129,7 @@ export function TerminalPanel({
|
||||
});
|
||||
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
|
||||
setIsRunning(false);
|
||||
onRunningChange?.(false);
|
||||
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||
});
|
||||
|
||||
@@ -144,19 +139,7 @@ export function TerminalPanel({
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Refit on height changes
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current || !terminalRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
const terminal = terminalRef.current;
|
||||
if (terminal) {
|
||||
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
|
||||
}
|
||||
});
|
||||
}, [height, api]);
|
||||
|
||||
// ResizeObserver for container width changes
|
||||
// ResizeObserver for container size changes (width or height from parent)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
@@ -174,100 +157,31 @@ export function TerminalPanel({
|
||||
return () => observer.disconnect();
|
||||
}, [api]);
|
||||
|
||||
// Drag-to-resize
|
||||
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartRef.current = { y: e.clientY, height };
|
||||
setIsDragging(true);
|
||||
|
||||
const handleDragMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
|
||||
const delta = dragStartRef.current.y - moveEvent.clientY;
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
|
||||
onHeightChange(nextHeight);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener('mousemove', handleDragMove);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDragMove);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
}, [height, onHeightChange]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
void api.restartTerminal().then((restarted) => {
|
||||
setSnapshot(restarted);
|
||||
setIsRunning(true);
|
||||
onRunningChange?.(true);
|
||||
terminalRef.current?.clear();
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void api.killTerminal();
|
||||
onClose();
|
||||
}, [api, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
|
||||
style={{ height, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
|
||||
onMouseDown={handleDragStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize terminal"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Header bar */}
|
||||
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-[var(--color-text-muted)]'}`} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
{snapshot ? `${snapshot.shell} — ${snapshot.cwd}` : 'Terminal'}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Minimize terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onMinimize}
|
||||
type="button"
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Restart terminal"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleRestart}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal body */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
import { PopoverToggleRow } from '@renderer/components/ui';
|
||||
@@ -757,3 +757,32 @@ export function InlineTerminalPill({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── InlineGitPill ─────────────────────────────────────────── */
|
||||
|
||||
export function InlineGitPill({
|
||||
isDirty,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isOpen}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
|
||||
isOpen
|
||||
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{isDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
|
||||
<GitBranch className="size-3" />
|
||||
<span>Git</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user