Add split-pane layout store and navigation shortcuts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -27,6 +27,7 @@ const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__";
|
||||
|
||||
type WorkspaceDesktopTabsRowProps = {
|
||||
paneId?: string;
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
normalizedServerId: string;
|
||||
@@ -47,6 +48,8 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
externalDndContext?: boolean;
|
||||
activeDragTabId?: string | null;
|
||||
};
|
||||
|
||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
@@ -59,7 +62,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "file") {
|
||||
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
|
||||
}
|
||||
return "";
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
@@ -96,7 +99,6 @@ function TabChip({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onPresentationChange,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -119,19 +121,14 @@ function TabChip({
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
||||
dragHandleProps: any;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onPresentationChange(tab.key, presentation);
|
||||
}, [onPresentationChange, presentation, tab.key]);
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||
@@ -281,6 +278,7 @@ function TabChip({
|
||||
}
|
||||
|
||||
export function WorkspaceDesktopTabsRow({
|
||||
paneId,
|
||||
tabs,
|
||||
activeTabKey,
|
||||
normalizedServerId,
|
||||
@@ -301,47 +299,12 @@ export function WorkspaceDesktopTabsRow({
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onReorderTabs,
|
||||
externalDndContext = false,
|
||||
activeDragTabId = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||
const [presentationsByKey, setPresentationsByKey] = useState<Map<string, WorkspaceTabPresentation>>(
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
const handlePresentationChange = useCallback(
|
||||
(tabKey: string, presentation: WorkspaceTabPresentation) => {
|
||||
setPresentationsByKey((current) => {
|
||||
const existing = current.get(tabKey);
|
||||
if (
|
||||
existing?.label === presentation.label &&
|
||||
existing?.subtitle === presentation.subtitle &&
|
||||
existing?.titleState === presentation.titleState &&
|
||||
existing?.statusBucket === presentation.statusBucket &&
|
||||
existing?.icon === presentation.icon
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(tabKey, presentation);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPresentationsByKey((current) => {
|
||||
const next = new Map<string, WorkspaceTabPresentation>();
|
||||
for (const tab of tabs) {
|
||||
const presentation = current.get(tab.key);
|
||||
if (presentation) {
|
||||
next.set(tab.key, presentation);
|
||||
}
|
||||
}
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [tabs]);
|
||||
|
||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
||||
@@ -371,14 +334,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
const tabLabelLengths = useMemo(
|
||||
() =>
|
||||
tabs.map((tab) => {
|
||||
const presentation = presentationsByKey.get(tab.key);
|
||||
if (presentation?.titleState === "loading") {
|
||||
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
|
||||
}
|
||||
const label = presentation?.label ?? getFallbackTabLabel(tab);
|
||||
const label = getFallbackTabLabel(tab);
|
||||
return label.length;
|
||||
}),
|
||||
[layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const { layout } = useWorkspaceTabLayout({
|
||||
@@ -410,6 +369,17 @@ export function WorkspaceDesktopTabsRow({
|
||||
useDragHandle
|
||||
disabled={tabs.length < 2}
|
||||
onDragEnd={onReorderTabs}
|
||||
externalDndContext={externalDndContext}
|
||||
activeId={activeDragTabId}
|
||||
getItemData={
|
||||
paneId
|
||||
? (tab) => ({
|
||||
kind: "workspace-tab",
|
||||
paneId,
|
||||
tabId: tab.tabId,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
renderItem={({ item: tab, index, dragHandleProps }) => {
|
||||
const isActive = tab.key === activeTabKey;
|
||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||
@@ -452,7 +422,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onPresentationChange={handlePresentationChange}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import { PaneProvider } from "@/panels/pane-context";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export interface WorkspacePaneContentProps {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onOpenWorkspaceFile: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onOpenTab,
|
||||
onCloseCurrentTab,
|
||||
onRetargetCurrentTab,
|
||||
onOpenWorkspaceFile,
|
||||
}: WorkspacePaneContentProps) {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
const Component = registration.component;
|
||||
|
||||
return (
|
||||
<PaneProvider
|
||||
value={{
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
target: tab.target,
|
||||
openTab: onOpenTab,
|
||||
closeCurrentTab: onCloseCurrentTab,
|
||||
retargetCurrentTab: onRetargetCurrentTab,
|
||||
openFileInWorkspace: onOpenWorkspaceFile,
|
||||
}}
|
||||
>
|
||||
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,7 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
];
|
||||
|
||||
const model = deriveWorkspaceTabModel({
|
||||
tabs: uiTabs,
|
||||
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
|
||||
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
@@ -43,11 +42,10 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
it("applies stored order and appends unordered tabs deterministically", () => {
|
||||
const model = deriveWorkspaceTabModel({
|
||||
tabs: [
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
],
|
||||
tabOrder: ["terminal_term-1", "agent_agent-b"],
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||
@@ -63,7 +61,6 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -82,7 +79,6 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||
],
|
||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||
focusedTabId: "agent_agent-a",
|
||||
preferredTarget: { kind: "agent", agentId: "agent-b" },
|
||||
});
|
||||
@@ -100,7 +96,6 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
tabOrder: ["draft_abc"],
|
||||
preferredTarget: { kind: "agent", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
@@ -123,7 +118,6 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
tabOrder: [],
|
||||
});
|
||||
|
||||
expect(model.tabs).toHaveLength(1);
|
||||
|
||||
@@ -111,52 +111,29 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||
|
||||
export function deriveWorkspaceTabModel(input: {
|
||||
tabs: WorkspaceTab[];
|
||||
tabOrder: string[];
|
||||
focusedTabId?: string | null;
|
||||
preferredTarget?: WorkspaceTabTarget | null;
|
||||
}): WorkspaceTabModel {
|
||||
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
||||
const tabs: WorkspaceDerivedTab[] = [];
|
||||
const openTabIds = new Set<string>();
|
||||
|
||||
const normalizedTabs = input.tabs
|
||||
.map((tab) => normalizeWorkspaceTab(tab))
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
for (const tab of input.tabs) {
|
||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tab of normalizedTabs) {
|
||||
tabsById.set(tab.tabId, {
|
||||
openTabIds.add(normalizedTab.tabId);
|
||||
tabs.push({
|
||||
descriptor: {
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
key: normalizedTab.tabId,
|
||||
tabId: normalizedTab.tabId,
|
||||
kind: normalizedTab.target.kind,
|
||||
target: normalizedTab.target,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const orderedTabIds: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const tabId of input.tabOrder) {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(normalizedTabId);
|
||||
orderedTabIds.push(normalizedTabId);
|
||||
}
|
||||
|
||||
for (const tabId of tabsById.keys()) {
|
||||
if (used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
orderedTabIds.push(tabId);
|
||||
}
|
||||
|
||||
const tabs = orderedTabIds
|
||||
.map((tabId) => tabsById.get(tabId) ?? null)
|
||||
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
||||
|
||||
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||
const preferredTarget = input.preferredTarget ?? null;
|
||||
const preferredTabId = (() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -34,15 +34,26 @@ export function useWorkspaceTabPresentation(input: {
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
key: input.tab.key,
|
||||
kind: input.tab.kind,
|
||||
label: descriptor.label,
|
||||
subtitle: descriptor.subtitle,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
key: input.tab.key,
|
||||
kind: input.tab.kind,
|
||||
label: descriptor.label,
|
||||
subtitle: descriptor.subtitle,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
}),
|
||||
[
|
||||
descriptor.icon,
|
||||
descriptor.label,
|
||||
descriptor.statusBucket,
|
||||
descriptor.subtitle,
|
||||
descriptor.titleState,
|
||||
input.tab.key,
|
||||
input.tab.kind,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
type WorkspaceTabIconProps = {
|
||||
|
||||
Reference in New Issue
Block a user