Skip to content

Commit ee1f78e

Browse files
MarkShawn2020claude
andcommitted
feat(terminal): 新增多 shell 类型支持的 terminal 创建按钮
- 新增 NewTerminalSplitButton 组件,支持 Terminal/Claude Code/Codex - VerticalFeatureTabs 项目头部添加新建 terminal 下拉按钮 - 单 tab 关闭时等价于关闭整个 panel - DRY: PanelGrid 复用 NewTerminalSplitButton 组件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fe9a513 commit ee1f78e

5 files changed

Lines changed: 239 additions & 66 deletions

File tree

src/components/GlobalHeader/VerticalFeatureTabs.tsx

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
import { ProjectLogo } from "@/views/Workspace/ProjectLogo";
5454
import { CreateFeatureDialog } from "./CreateFeatureDialog";
5555
import { SessionDropdownMenuItems } from "@/components/shared/SessionMenuItems";
56+
import { NewTerminalSplitButton } from "@/components/ui/new-terminal-button";
5657
import type { Feature, WorkspaceData, WorkspaceProject } from "@/views/Workspace/types";
5758

5859
const MIN_WIDTH = 180;
@@ -810,6 +811,109 @@ function ProjectSessionsGroup({
810811
}
811812
};
812813

814+
const handleNewTerminal = async (command?: string) => {
815+
let savedWorkspace: WorkspaceData | null = null;
816+
817+
setWorkspace((currentWorkspace) => {
818+
if (!currentWorkspace) return currentWorkspace;
819+
820+
const currentProject = currentWorkspace.projects.find((p) => p.id === project.id);
821+
if (!currentProject) return currentWorkspace;
822+
823+
const targetFeature = currentProject.features.find((f) => !f.archived);
824+
825+
if (!targetFeature) {
826+
savedWorkspace = {
827+
...currentWorkspace,
828+
active_project_id: project.id,
829+
};
830+
return savedWorkspace;
831+
}
832+
833+
const panelId = targetFeature.panels[0]?.id;
834+
const title = command === "claude" ? "Claude Code" : command === "codex" ? "Codex" : "Terminal";
835+
836+
if (!panelId) {
837+
const newPanelId = crypto.randomUUID();
838+
const ptySessionId = crypto.randomUUID();
839+
const ptyId = crypto.randomUUID();
840+
841+
const newPanel = {
842+
id: newPanelId,
843+
sessions: [{ id: ptySessionId, pty_id: ptyId, title, command }],
844+
active_session_id: ptySessionId,
845+
is_shared: false,
846+
cwd: project.path,
847+
};
848+
849+
const newProjects = currentWorkspace.projects.map((p) => {
850+
if (p.id !== project.id) return p;
851+
return {
852+
...p,
853+
features: p.features.map((f) => {
854+
if (f.id !== targetFeature.id) return f;
855+
return {
856+
...f,
857+
panels: [...f.panels, newPanel],
858+
layout: { type: "panel" as const, panelId: newPanelId },
859+
};
860+
}),
861+
active_feature_id: targetFeature.id,
862+
view_mode: "features" as const,
863+
};
864+
});
865+
866+
savedWorkspace = {
867+
...currentWorkspace,
868+
projects: newProjects,
869+
active_project_id: project.id,
870+
};
871+
return savedWorkspace;
872+
} else {
873+
const ptySessionId = crypto.randomUUID();
874+
const ptyId = crypto.randomUUID();
875+
876+
const newProjects = currentWorkspace.projects.map((p) => {
877+
if (p.id !== project.id) return p;
878+
return {
879+
...p,
880+
features: p.features.map((f) => {
881+
if (f.id !== targetFeature.id) return f;
882+
return {
883+
...f,
884+
panels: f.panels.map((panel) => {
885+
if (panel.id !== panelId) return panel;
886+
return {
887+
...panel,
888+
sessions: [
889+
...(panel.sessions || []),
890+
{ id: ptySessionId, pty_id: ptyId, title, command },
891+
],
892+
active_session_id: ptySessionId,
893+
};
894+
}),
895+
};
896+
}),
897+
active_feature_id: targetFeature.id,
898+
view_mode: "features" as const,
899+
};
900+
});
901+
902+
savedWorkspace = {
903+
...currentWorkspace,
904+
projects: newProjects,
905+
active_project_id: project.id,
906+
};
907+
return savedWorkspace;
908+
}
909+
});
910+
911+
if (savedWorkspace) {
912+
await invoke("workspace_save", { data: savedWorkspace });
913+
navigate({ type: "workspace", projectId: project.id, mode: "features" });
914+
}
915+
};
916+
813917
const projectDisplayName = project.name
814918
.split(/[-_]/)
815919
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
@@ -819,7 +923,7 @@ function ProjectSessionsGroup({
819923
<div className="px-2">
820924
{/* Project Header */}
821925
<div
822-
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer transition-colors ${
926+
className={`group flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer transition-colors ${
823927
isActiveProject
824928
? "bg-primary/10 text-primary"
825929
: "text-ink hover:bg-card-alt"
@@ -853,6 +957,13 @@ function ProjectSessionsGroup({
853957
<span className="text-xs text-muted-foreground">
854958
{isLoading ? "..." : filteredSessions.length}
855959
</span>
960+
961+
{/* New Terminal Button */}
962+
<NewTerminalSplitButton
963+
variant="icon"
964+
onSelect={handleNewTerminal}
965+
className="opacity-0 group-hover:opacity-100"
966+
/>
856967
</div>
857968

858969
{/* Sessions List */}

src/components/PanelGrid/PanelGrid.tsx

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { useCallback, useEffect, useState } from "react";
22
import { Allotment } from "allotment";
33
import "allotment/dist/style.css";
4-
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, DrawingPinFilledIcon, PlusIcon } from "@radix-ui/react-icons";
4+
import { ChevronLeftIcon, ChevronRightIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons";
55
import { SessionPanel } from "./SessionPanel";
66
import type { LayoutNode } from "../../views/Workspace/types";
7-
import {
8-
DropdownMenu,
9-
DropdownMenuContent,
10-
DropdownMenuItem,
11-
DropdownMenuTrigger,
12-
} from "../ui/dropdown-menu";
7+
import { NewTerminalSplitButton } from "../ui/new-terminal-button";
138

149
export interface SessionState {
1510
id: string;
@@ -184,33 +179,7 @@ export function PanelGrid({
184179
<div className="text-center">
185180
<p className="text-muted-foreground mb-4">No terminals open</p>
186181
{onInitialPanelCreate && (
187-
<div className="inline-flex rounded-lg overflow-hidden">
188-
<button
189-
onClick={() => onInitialPanelCreate()}
190-
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
191-
>
192-
<PlusIcon className="w-4 h-4" />
193-
New Terminal
194-
</button>
195-
<DropdownMenu>
196-
<DropdownMenuTrigger asChild>
197-
<button className="px-2 py-2 bg-primary text-primary-foreground hover:bg-primary/90 border-l border-primary-foreground/20 transition-colors">
198-
<ChevronDownIcon className="w-4 h-4" />
199-
</button>
200-
</DropdownMenuTrigger>
201-
<DropdownMenuContent align="end">
202-
<DropdownMenuItem onClick={() => onInitialPanelCreate()}>
203-
Terminal
204-
</DropdownMenuItem>
205-
<DropdownMenuItem onClick={() => onInitialPanelCreate("claude")}>
206-
Claude Code
207-
</DropdownMenuItem>
208-
<DropdownMenuItem onClick={() => onInitialPanelCreate("codex")}>
209-
Codex
210-
</DropdownMenuItem>
211-
</DropdownMenuContent>
212-
</DropdownMenu>
213-
</div>
182+
<NewTerminalSplitButton onSelect={onInitialPanelCreate} />
214183
)}
215184
</div>
216185
</div>

src/components/PanelGrid/SessionPanel.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,20 +144,18 @@ export const SessionPanel = memo(function SessionPanel({
144144
fallback={titleFallback}
145145
onRename={handleTitleChange(session.id)}
146146
/>
147-
{panel.sessions.length > 1 && (
148-
<span
149-
role="button"
150-
tabIndex={-1}
151-
onClick={(e) => {
152-
e.stopPropagation();
153-
e.preventDefault();
154-
onSessionClose(session.id);
155-
}}
156-
className="absolute right-1 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-card-alt transition-opacity cursor-pointer"
157-
>
158-
<Cross2Icon className="w-3 h-3" />
159-
</span>
160-
)}
147+
<span
148+
role="button"
149+
tabIndex={-1}
150+
onClick={(e) => {
151+
e.stopPropagation();
152+
e.preventDefault();
153+
onSessionClose(session.id);
154+
}}
155+
className="absolute right-1 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-card-alt transition-opacity cursor-pointer"
156+
>
157+
<Cross2Icon className="w-3 h-3" />
158+
</span>
161159
</TabsTrigger>
162160
))}
163161
</TabsList>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { PlusIcon, ChevronDownIcon } from "@radix-ui/react-icons";
2+
import {
3+
DropdownMenu,
4+
DropdownMenuTrigger,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
} from "./dropdown-menu";
8+
9+
export type TerminalType = "terminal" | "claude" | "codex";
10+
11+
export interface TerminalOption {
12+
type: TerminalType;
13+
label: string;
14+
command?: string;
15+
}
16+
17+
export const TERMINAL_OPTIONS: TerminalOption[] = [
18+
{ type: "terminal", label: "Terminal" },
19+
{ type: "claude", label: "Claude Code", command: "claude" },
20+
{ type: "codex", label: "Codex", command: "codex" },
21+
];
22+
23+
interface NewTerminalSplitButtonProps {
24+
onSelect: (command?: string) => void;
25+
variant?: "primary" | "icon";
26+
className?: string;
27+
}
28+
29+
/**
30+
* Split button for creating new terminals with multiple shell options.
31+
* - Primary variant: Full button with "New Terminal" text (for empty state)
32+
* - Icon variant: Compact icon-only button (for inline use)
33+
*/
34+
export function NewTerminalSplitButton({
35+
onSelect,
36+
variant = "primary",
37+
className = "",
38+
}: NewTerminalSplitButtonProps) {
39+
if (variant === "icon") {
40+
return (
41+
<DropdownMenu>
42+
<DropdownMenuTrigger asChild>
43+
<button
44+
className={`p-0.5 text-muted-foreground hover:text-ink transition-colors ${className}`}
45+
title="New terminal"
46+
onClick={(e) => e.stopPropagation()}
47+
>
48+
<PlusIcon className="w-3.5 h-3.5" />
49+
</button>
50+
</DropdownMenuTrigger>
51+
<DropdownMenuContent align="end">
52+
{TERMINAL_OPTIONS.map((opt) => (
53+
<DropdownMenuItem
54+
key={opt.type}
55+
onClick={(e) => {
56+
e.stopPropagation();
57+
onSelect(opt.command);
58+
}}
59+
>
60+
{opt.label}
61+
</DropdownMenuItem>
62+
))}
63+
</DropdownMenuContent>
64+
</DropdownMenu>
65+
);
66+
}
67+
68+
// Primary variant - full split button
69+
return (
70+
<div className={`inline-flex rounded-lg overflow-hidden ${className}`}>
71+
<button
72+
onClick={() => onSelect()}
73+
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
74+
>
75+
<PlusIcon className="w-4 h-4" />
76+
New Terminal
77+
</button>
78+
<DropdownMenu>
79+
<DropdownMenuTrigger asChild>
80+
<button className="px-2 py-2 bg-primary text-primary-foreground hover:bg-primary/90 border-l border-primary-foreground/20 transition-colors">
81+
<ChevronDownIcon className="w-4 h-4" />
82+
</button>
83+
</DropdownMenuTrigger>
84+
<DropdownMenuContent align="end">
85+
{TERMINAL_OPTIONS.map((opt) => (
86+
<DropdownMenuItem key={opt.type} onClick={() => onSelect(opt.command)}>
87+
{opt.label}
88+
</DropdownMenuItem>
89+
))}
90+
</DropdownMenuContent>
91+
</DropdownMenu>
92+
</div>
93+
);
94+
}

0 commit comments

Comments
 (0)