feat(workspace): add 1/2/4-pane split terminal view toggle
This commit is contained in:
@@ -122,6 +122,7 @@
|
|||||||
"proof.ticketReport": "Task report",
|
"proof.ticketReport": "Task report",
|
||||||
"splitView.emptyPane": "No lane selected for this pane.",
|
"splitView.emptyPane": "No lane selected for this pane.",
|
||||||
"splitView.paneLaneLabel": "Pane lane selector",
|
"splitView.paneLaneLabel": "Pane lane selector",
|
||||||
|
"splitView.paneCount": "{{count}} pane",
|
||||||
"splitView.pickLane": "Pick a lane",
|
"splitView.pickLane": "Pick a lane",
|
||||||
"statusDead": "DEAD",
|
"statusDead": "DEAD",
|
||||||
"title": "Lanes",
|
"title": "Lanes",
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||||
|
"splitView.paneCount": "{{count}} ô",
|
||||||
"splitView.pickLane": "Chọn lane",
|
"splitView.pickLane": "Chọn lane",
|
||||||
"statusDead": "ĐÃ CHẾT",
|
"statusDead": "ĐÃ CHẾT",
|
||||||
"title": "Làn đường",
|
"title": "Làn đường",
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
|||||||
import LaneCard from "../components/lanes/LaneCard";
|
import LaneCard from "../components/lanes/LaneCard";
|
||||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
|
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||||
|
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -80,6 +82,28 @@ export function Workspace() {
|
|||||||
const [defaultCwd, setDefaultCwd] = useState<string>("");
|
const [defaultCwd, setDefaultCwd] = useState<string>("");
|
||||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||||
|
|
||||||
|
// Split view state
|
||||||
|
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||||
|
|
||||||
|
const setLayout = useCallback((layout: SplitLayout) => {
|
||||||
|
setSplitView((prev) => {
|
||||||
|
const paneLaneIds = Array.from({ length: layout }, (_, i) => prev.paneLaneIds[i] ?? null);
|
||||||
|
const next = { layout, paneLaneIds };
|
||||||
|
writeSplitViewState(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPaneLaneId = useCallback((index: number, id: number) => {
|
||||||
|
setSplitView((prev) => {
|
||||||
|
const paneLaneIds = [...prev.paneLaneIds];
|
||||||
|
paneLaneIds[index] = id;
|
||||||
|
const next = { ...prev, paneLaneIds };
|
||||||
|
writeSplitViewState(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
||||||
const refreshLanes = useCallback(async () => {
|
const refreshLanes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -167,6 +191,20 @@ export function Workspace() {
|
|||||||
[refreshLanes]
|
[refreshLanes]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up stale pane lane IDs when lanes load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lanes.length) return;
|
||||||
|
setSplitView((prev) => {
|
||||||
|
const paneLaneIds = prev.paneLaneIds.map((id) =>
|
||||||
|
id !== null && lanes.some((l) => l.id === id) ? id : null
|
||||||
|
);
|
||||||
|
if (paneLaneIds.every((id, i) => id === prev.paneLaneIds[i])) return prev;
|
||||||
|
const next = { ...prev, paneLaneIds };
|
||||||
|
writeSplitViewState(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [lanes]);
|
||||||
|
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
@@ -514,6 +552,24 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{([1, 2, 4] as const).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={splitView.layout === n}
|
||||||
|
onClick={() => setLayout(n)}
|
||||||
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
|
splitView.layout === n
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tLanes("splitView.paneCount", { count: n })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{splitView.layout === 1 ? (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
laneId={selectedLaneId}
|
laneId={selectedLaneId}
|
||||||
@@ -529,12 +585,57 @@ export function Workspace() {
|
|||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`grid flex-1 min-h-0 gap-3 ${
|
||||||
|
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{splitView.paneLaneIds.map((id, i) => (
|
||||||
|
<LaneConsolePane
|
||||||
|
key={i}
|
||||||
|
lanes={lanes}
|
||||||
|
laneId={id}
|
||||||
|
showLaneSelector
|
||||||
|
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
|
||||||
|
onLaneCreated={(lane) =>
|
||||||
|
setLanes((prev) =>
|
||||||
|
prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentLane && (
|
{!currentLane && (
|
||||||
<div className="flex min-h-0 flex-col gap-2">
|
<div className="flex min-h-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{([1, 2, 4] as const).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={splitView.layout === n}
|
||||||
|
onClick={() => setLayout(n)}
|
||||||
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
|
splitView.layout === n
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tLanes("splitView.paneCount", { count: n })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{splitView.layout === 1 ? (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
laneId={selectedLaneId}
|
laneId={selectedLaneId}
|
||||||
@@ -550,6 +651,33 @@ export function Workspace() {
|
|||||||
defaultCwd={defaultCwd}
|
defaultCwd={defaultCwd}
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
onHasActiveRunChange={setPaneHasActiveRun}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`grid flex-1 min-h-0 gap-3 ${
|
||||||
|
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{splitView.paneLaneIds.map((id, i) => (
|
||||||
|
<LaneConsolePane
|
||||||
|
key={i}
|
||||||
|
lanes={lanes}
|
||||||
|
laneId={id}
|
||||||
|
showLaneSelector
|
||||||
|
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
|
||||||
|
onLaneCreated={(lane) =>
|
||||||
|
setLanes((prev) =>
|
||||||
|
prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binaryStatus={binaryStatus}
|
||||||
|
cwdSuggestions={cwdSuggestions}
|
||||||
|
activeRuns={activeRuns}
|
||||||
|
wsConnected={wsConnected}
|
||||||
|
defaultCwd={defaultCwd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user